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 ( +
+ { + const isChecked = Object.keys(value ?? {}).length > 0; + return ( + { + if (active) { + bookingsLimitFormMethods.setValue("bookingLimits", { + PER_DAY: 1, + }); + } else { + bookingsLimitFormMethods.setValue("bookingLimits", {}); + } + handleSubmit(bookingsLimitFormMethods.getValues()); + }} + switchContainerClassName={classNames( + "border-subtle mt-6 rounded-lg border py-6 px-4 sm:px-6", + isChecked && "rounded-b-none" + )} + childrenClassName="lg:ml-0"> +
+ +
+ + + +
+ ); + }} + /> + + ); +}; + +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;