diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 5590e6f019..80ca94703f 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -4,7 +4,7 @@ Contributions are what make the open source community such an amazing place to l
- Before jumping into a PR be sure to search [existing PRs](https://github.com/calcom/cal.com/pulls) or [issues](https://github.com/calcom/cal.com/issues) for an open or closed item that relates to your submission.
-## Priorities
+## Priorities
@@ -57,7 +57,6 @@ Contributions are what make the open source community such an amazing place to l
-
## Developing
The development branch is `main`. This is the branch that all pull
diff --git a/README.md b/README.md
index f93f3f7483..239f4a3767 100644
--- a/README.md
+++ b/README.md
@@ -350,17 +350,16 @@ We have a list of [help wanted](https://github.com/calcom/cal.com/issues?q=is:is
-
+
### Contributors
-
+
-
### Translations
diff --git a/apps/web/components/eventtype/EventLimitsTab.tsx b/apps/web/components/eventtype/EventLimitsTab.tsx
index ffa7ba5e14..7a3328a547 100644
--- a/apps/web/components/eventtype/EventLimitsTab.tsx
+++ b/apps/web/components/eventtype/EventLimitsTab.tsx
@@ -1,9 +1,11 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import * as RadioGroup from "@radix-ui/react-radio-group";
import type { EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
+import type { Key } from "react";
import React, { useEffect, useState } from "react";
import type { UseFormRegisterReturn } from "react-hook-form";
import { Controller, useFormContext, useWatch } from "react-hook-form";
+import type { SingleValue } from "react-select";
import { classNames } from "@calcom/lib";
import type { DurationType } from "@calcom/lib/convertToNewDurationType";
@@ -11,8 +13,17 @@ import convertToNewDurationType from "@calcom/lib/convertToNewDurationType";
import findDurationType from "@calcom/lib/findDurationType";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { PeriodType } from "@calcom/prisma/client";
-import type { BookingLimit } from "@calcom/types/Calendar";
-import { Button, DateRangePicker, Input, InputField, Label, Select, SettingsToggle } from "@calcom/ui";
+import type { IntervalLimit } from "@calcom/types/Calendar";
+import {
+ Button,
+ DateRangePicker,
+ Input,
+ InputField,
+ Label,
+ Select,
+ SettingsToggle,
+ TextField,
+} from "@calcom/ui";
import { FiPlus, FiTrash } from "@calcom/ui/components/icon";
const MinimumBookingNoticeInput = React.forwardRef<
@@ -260,7 +271,34 @@ export const EventLimitsTab = ({ eventType }: Pick
-
+
+
+ )}
+ />
+
+ (
+ 0}
+ onCheckedChange={(active) => {
+ if (active) {
+ formMethods.setValue("durationLimits", {
+ PER_DAY: 60,
+ });
+ } else {
+ formMethods.setValue("durationLimits", {});
+ }
+ }}>
+
)}
/>
@@ -348,124 +386,158 @@ export const EventLimitsTab = ({ eventType }: Pick {
+type IntervalLimitsKey = keyof IntervalLimit;
+
+const intervalOrderKeys = ["PER_DAY", "PER_WEEK", "PER_MONTH", "PER_YEAR"] as const;
+
+const INTERVAL_LIMIT_OPTIONS = intervalOrderKeys.map((key) => ({
+ value: key as keyof IntervalLimit,
+ label: `Per ${key.split("_")[1].toLocaleLowerCase()}`,
+}));
+
+type IntervalLimitItemProps = {
+ key: Key;
+ limitKey: IntervalLimitsKey;
+ step: number;
+ value: number;
+ textFieldSuffix?: string;
+ selectOptions: { value: keyof IntervalLimit; label: string }[];
+ hasDeleteButton?: boolean;
+ onDelete: (intervalLimitsKey: IntervalLimitsKey) => void;
+ onLimitChange: (intervalLimitsKey: IntervalLimitsKey, limit: number) => void;
+ onIntervalSelect: (interval: SingleValue<{ value: keyof IntervalLimit; label: string }>) => void;
+};
+
+const IntervalLimitItem = ({
+ limitKey,
+ step,
+ value,
+ textFieldSuffix,
+ selectOptions,
+ hasDeleteButton,
+ onDelete,
+ onLimitChange,
+ onIntervalSelect,
+}: IntervalLimitItemProps) => {
+ return (
+
+ onLimitChange(limitKey, parseInt(e.target.value))}
+ />
+ option.value === limitKey)}
+ onChange={onIntervalSelect}
+ />
+ {hasDeleteButton && (
+ onDelete(limitKey)} />
+ )}
+
+ );
+};
+
+type IntervalLimitsManagerProps = {
+ propertyName: K;
+ defaultLimit: number;
+ step: number;
+ textFieldSuffix?: string;
+};
+
+const IntervalLimitsManager = ({
+ propertyName,
+ defaultLimit,
+ step,
+ textFieldSuffix,
+}: IntervalLimitsManagerProps) => {
const { watch, setValue, control } = useFormContext();
- const watchBookingLimits = watch("bookingLimits");
+ const watchIntervalLimits = watch(propertyName);
const { t } = useLocale();
const [animateRef] = useAutoAnimate();
- const BOOKING_LIMIT_OPTIONS: {
- value: keyof BookingLimit;
- label: string;
- }[] = [
- {
- value: "PER_DAY",
- label: "Per Day",
- },
- {
- value: "PER_WEEK",
- label: "Per Week",
- },
- {
- value: "PER_MONTH",
- label: "Per Month",
- },
- {
- value: "PER_YEAR",
- label: "Per Year",
- },
- ];
-
return (
{
- const currentBookingLimits = value;
+ const currentIntervalLimits = value;
+
+ const addLimit = () => {
+ if (!currentIntervalLimits || !watchIntervalLimits) return;
+ const currentKeys = Object.keys(watchIntervalLimits);
+
+ const [rest] = Object.values(INTERVAL_LIMIT_OPTIONS).filter(
+ (option) => !currentKeys.includes(option.value)
+ );
+ if (!rest || !currentKeys.length) return;
+ //currentDurationLimits is always defined so can be casted
+ // @ts-expect-error FIXME Fix these typings
+ setValue(propertyName, {
+ ...watchIntervalLimits,
+ [rest.value]: defaultLimit,
+ });
+ };
+
return (
- {currentBookingLimits &&
- watchBookingLimits &&
- Object.entries(currentBookingLimits)
- .sort(([limitkeyA], [limitKeyB]) => {
+ {currentIntervalLimits &&
+ watchIntervalLimits &&
+ Object.entries(currentIntervalLimits)
+ .sort(([limitKeyA], [limitKeyB]) => {
return (
- validationOrderKeys.indexOf(limitkeyA as BookingLimitsKey) -
- validationOrderKeys.indexOf(limitKeyB as BookingLimitsKey)
+ intervalOrderKeys.indexOf(limitKeyA as IntervalLimitsKey) -
+ intervalOrderKeys.indexOf(limitKeyB as IntervalLimitsKey)
);
})
- .map(([key, bookingAmount]) => {
- const bookingLimitKey = key as BookingLimitsKey;
+ .map(([key, value]) => {
+ const limitKey = key as IntervalLimitsKey;
return (
-
- {
- const val = e.target.value;
- setValue(`bookingLimits.${bookingLimitKey}`, parseInt(val));
- }}
- />
- !Object.keys(currentBookingLimits).includes(option.value)
- )}
- isSearchable={false}
- defaultValue={BOOKING_LIMIT_OPTIONS.find((option) => option.value === key)}
- onChange={(val) => {
- const current = currentBookingLimits;
- const currentValue = watchBookingLimits[bookingLimitKey];
+ 1}
+ selectOptions={INTERVAL_LIMIT_OPTIONS.filter(
+ (option) => !Object.keys(currentIntervalLimits).includes(option.value)
+ )}
+ onLimitChange={(intervalLimitKey, val) =>
+ // @ts-expect-error FIXME Fix these typings
+ setValue(`${propertyName}.${intervalLimitKey}`, val)
+ }
+ onDelete={(intervalLimitKey) => {
+ const current = currentIntervalLimits;
+ delete current[intervalLimitKey];
+ onChange(current);
+ }}
+ onIntervalSelect={(interval) => {
+ const current = currentIntervalLimits;
+ const currentValue = watchIntervalLimits[limitKey];
- // Removes limit from previous selected value (eg when changed from per_week to per_month, we unset per_week here)
- delete current[bookingLimitKey];
- const newData = {
- ...current,
- // Set limit to new selected value (in the example above this means we set the limit to per_week here).
- [val?.value as BookingLimitsKey]: currentValue,
- };
- onChange(newData);
- }}
- />
- {
- const current = currentBookingLimits;
- delete current[key as BookingLimitsKey];
- onChange(current);
- }}
- />
-
+ // Removes limit from previous selected value (eg when changed from per_week to per_month, we unset per_week here)
+ delete current[limitKey];
+ const newData = {
+ ...current,
+ // Set limit to new selected value (in the example above this means we set the limit to per_week here).
+ [interval?.value as IntervalLimitsKey]: currentValue,
+ };
+ onChange(newData);
+ }}
+ />
);
})}
- {currentBookingLimits && Object.keys(currentBookingLimits).length <= 3 && (
- {
- if (!currentBookingLimits || !watchBookingLimits) return;
- const currentKeys = Object.keys(watchBookingLimits);
-
- const rest = Object.values(BOOKING_LIMIT_OPTIONS).filter(
- (option) => !currentKeys.includes(option.value)
- );
- if (!rest || !currentKeys) return;
- //currentBookingLimits is always defined so can be casted
-
- setValue("bookingLimits", {
- ...watchBookingLimits,
- [rest[0].value]: 1,
- });
- }}>
+ {currentIntervalLimits && Object.keys(currentIntervalLimits).length <= 3 && (
+
{t("add_limit")}
)}
diff --git a/apps/web/pages/event-types/[type]/index.tsx b/apps/web/pages/event-types/[type]/index.tsx
index 82f6d2dd8d..31875ac27e 100644
--- a/apps/web/pages/event-types/[type]/index.tsx
+++ b/apps/web/pages/event-types/[type]/index.tsx
@@ -10,7 +10,7 @@ import { z } from "zod";
import { validateCustomEventName } from "@calcom/core/event";
import type { EventLocationType } from "@calcom/core/location";
-import { validateBookingLimitOrder } from "@calcom/lib";
+import { validateIntervalLimitOrder } from "@calcom/lib";
import { CAL_URL } from "@calcom/lib/constants";
import getEventTypeById from "@calcom/lib/getEventTypeById";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -22,7 +22,7 @@ import { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
import type { customInputSchema, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
-import type { BookingLimit, RecurringEvent } from "@calcom/types/Calendar";
+import type { IntervalLimit, RecurringEvent } from "@calcom/types/Calendar";
import { Form, showToast } from "@calcom/ui";
import { asStringOrThrow } from "@lib/asStringOrNull";
@@ -86,7 +86,8 @@ export type FormValues = {
externalId: string;
};
successRedirectUrl: string;
- bookingLimits?: BookingLimit;
+ durationLimits?: IntervalLimit;
+ bookingLimits?: IntervalLimit;
hosts: { userId: number; isFixed: boolean }[];
bookingFields: z.infer;
};
@@ -194,6 +195,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
description: eventType.description ?? undefined,
schedule: eventType.schedule || undefined,
bookingLimits: eventType.bookingLimits || undefined,
+ durationLimits: eventType.durationLimits || undefined,
length: eventType.length,
hidden: eventType.hidden,
periodDates: {
@@ -303,6 +305,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
seatsPerTimeSlot,
seatsShowAttendees,
bookingLimits,
+ durationLimits,
recurringEvent,
locations,
metadata,
@@ -316,10 +319,15 @@ const EventTypePage = (props: EventTypeSetupProps) => {
} = values;
if (bookingLimits) {
- const isValid = validateBookingLimitOrder(bookingLimits);
+ const isValid = validateIntervalLimitOrder(bookingLimits);
if (!isValid) throw new Error(t("event_setup_booking_limits_error"));
}
+ if (durationLimits) {
+ const isValid = validateIntervalLimitOrder(durationLimits);
+ if (!isValid) throw new Error(t("event_setup_duration_limits_error"));
+ }
+
if (metadata?.multipleDuration !== undefined) {
if (metadata?.multipleDuration.length < 1) {
throw new Error(t("event_setup_multiple_duration_error"));
@@ -341,6 +349,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
beforeEventBuffer: beforeBufferTime,
afterEventBuffer: afterBufferTime,
bookingLimits,
+ durationLimits,
seatsPerTimeSlot,
seatsShowAttendees,
metadata,
diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json
index e1b7041017..391b5e1e9f 100644
--- a/apps/web/public/static/locales/en/common.json
+++ b/apps/web/public/static/locales/en/common.json
@@ -1156,7 +1156,8 @@
"event_advanced_tab_title": "Advanced",
"event_setup_multiple_duration_error": "Event Setup: Multiple durations requires at least 1 option.",
"event_setup_multiple_duration_default_error": "Event Setup: Please select a valid default duration.",
- "event_setup_booking_limits_error": "Booking limits must be in accending order. [day,week,month,year]",
+ "event_setup_booking_limits_error": "Booking limits must be in ascending order. [day, week, month, year]",
+ "event_setup_duration_limits_error": "Duration limits must be in ascending order. [day, week, month, year]",
"select_which_cal": "Select which calendar to add bookings to",
"custom_event_name": "Custom event name",
"custom_event_name_description": "Create customised event names to display on calendar event",
@@ -1340,6 +1341,8 @@
"report_app": "Report app",
"limit_booking_frequency": "Limit booking frequency",
"limit_booking_frequency_description": "Limit how many times this event can be booked",
+ "limit_total_booking_duration": "Limit total booking duration",
+ "limit_total_booking_duration_description": "Limit total amount of time that this event can be booked",
"add_limit": "Add Limit",
"team_name_required": "Team name required",
"show_attendees": "Share attendee information between guests",
@@ -1443,6 +1446,7 @@
"calcom_is_better_with_team": "Cal.com is better with teams",
"add_your_team_members": "Add your team members to your event types. Use collective scheduling to include everyone or find the most suitable person with round robin scheduling.",
"booking_limit_reached": "Booking Limit for this event type has been reached",
+ "duration_limit_reached": "Duration Limit for this event type has been reached",
"admin_has_disabled": "An admin has disabled {{appName}}",
"disabled_app_affects_event_type": "An admin has disabled {{appName}} which affects your event type {{eventType}}",
"disable_payment_app": "The admin has disabled {{appName}} which affects your event type {{title}}. Attendees are still able to book this type of event but will not be prompted to pay. You may hide hide the event type to prevent this until your admin renables your payment method.",
diff --git a/apps/web/test/lib/checkBookingLimits.test.ts b/apps/web/test/lib/checkBookingLimits.test.ts
index 8054535038..2dad1bc828 100644
--- a/apps/web/test/lib/checkBookingLimits.test.ts
+++ b/apps/web/test/lib/checkBookingLimits.test.ts
@@ -1,14 +1,14 @@
import dayjs from "@calcom/dayjs";
-import { validateBookingLimitOrder } from "@calcom/lib/isBookingLimits";
-import { checkBookingLimits, checkLimit } from "@calcom/lib/server";
-import type { BookingLimit } from "@calcom/types/Calendar";
+import { validateIntervalLimitOrder } from "@calcom/lib";
+import { checkBookingLimits, checkBookingLimit } from "@calcom/lib/server";
+import type { IntervalLimit } from "@calcom/types/Calendar";
import { prismaMock } from "../../../../tests/config/singleton";
type Mockdata = {
id: number;
startDate: Date;
- bookingLimits: BookingLimit;
+ bookingLimits: IntervalLimit;
};
const MOCK_DATA: Mockdata = {
@@ -63,7 +63,7 @@ describe("Check Booking Limits Tests", () => {
it("Should handle mutiple limits correctly", async () => {
prismaMock.booking.count.mockResolvedValue(1);
expect(
- checkLimit({
+ checkBookingLimit({
key: "PER_DAY",
limitingNumber: 2,
eventStartDate: MOCK_DATA.startDate,
@@ -72,7 +72,7 @@ describe("Check Booking Limits Tests", () => {
).resolves.not.toThrow();
prismaMock.booking.count.mockResolvedValue(3);
expect(
- checkLimit({
+ checkBookingLimit({
key: "PER_WEEK",
limitingNumber: 2,
eventStartDate: MOCK_DATA.startDate,
@@ -83,7 +83,7 @@ describe("Check Booking Limits Tests", () => {
it("Should return busyTimes when set", async () => {
prismaMock.booking.count.mockResolvedValue(2);
expect(
- checkLimit({
+ checkBookingLimit({
key: "PER_DAY",
limitingNumber: 2,
eventStartDate: MOCK_DATA.startDate,
@@ -99,21 +99,21 @@ describe("Check Booking Limits Tests", () => {
describe("Booking limit validation", () => {
it("Should validate a correct limit", () => {
- expect(validateBookingLimitOrder({ PER_DAY: 3, PER_MONTH: 5 })).toBe(true);
+ expect(validateIntervalLimitOrder({ PER_DAY: 3, PER_MONTH: 5 })).toBe(true);
});
it("Should invalidate an incorrect limit", () => {
- expect(validateBookingLimitOrder({ PER_DAY: 9, PER_MONTH: 5 })).toBe(false);
+ expect(validateIntervalLimitOrder({ PER_DAY: 9, PER_MONTH: 5 })).toBe(false);
});
it("Should validate a correct limit with 'gaps' ", () => {
- expect(validateBookingLimitOrder({ PER_DAY: 9, PER_YEAR: 25 })).toBe(true);
+ expect(validateIntervalLimitOrder({ PER_DAY: 9, PER_YEAR: 25 })).toBe(true);
});
it("Should validate a correct limit with equal values ", () => {
- expect(validateBookingLimitOrder({ PER_DAY: 1, PER_YEAR: 1 })).toBe(true);
+ expect(validateIntervalLimitOrder({ PER_DAY: 1, PER_YEAR: 1 })).toBe(true);
});
it("Should validate a correct with empty", () => {
- expect(validateBookingLimitOrder({})).toBe(true);
+ expect(validateIntervalLimitOrder({})).toBe(true);
});
});
diff --git a/apps/web/test/lib/checkDurationLimits.test.ts b/apps/web/test/lib/checkDurationLimits.test.ts
new file mode 100644
index 0000000000..20c0f12b00
--- /dev/null
+++ b/apps/web/test/lib/checkDurationLimits.test.ts
@@ -0,0 +1,128 @@
+import dayjs from "@calcom/dayjs";
+import { validateIntervalLimitOrder } from "@calcom/lib";
+import { checkDurationLimit, checkDurationLimits } from "@calcom/lib/server";
+
+import { prismaMock } from "../../../../tests/config/singleton";
+
+type MockData = {
+ id: number;
+ startDate: Date;
+};
+
+const MOCK_DATA: MockData = {
+ id: 1,
+ startDate: dayjs("2022-09-30T09:00:00+01:00").toDate(),
+};
+
+// Path: apps/web/test/lib/checkDurationLimits.ts
+describe("Check Duration Limits Tests", () => {
+ it("Should return no errors if limit is not reached", async () => {
+ prismaMock.$queryRaw.mockResolvedValue([{ totalMinutes: 0 }]);
+ await expect(
+ checkDurationLimits({ PER_DAY: 60 }, MOCK_DATA.startDate, MOCK_DATA.id)
+ ).resolves.toBeTruthy();
+ });
+ it("Should throw an error if limit is reached", async () => {
+ prismaMock.$queryRaw.mockResolvedValue([{ totalMinutes: 60 }]);
+ await expect(
+ checkDurationLimits({ PER_DAY: 60 }, MOCK_DATA.startDate, MOCK_DATA.id)
+ ).rejects.toThrowError();
+ });
+ it("Should pass with multiple duration limits", async () => {
+ prismaMock.$queryRaw.mockResolvedValue([{ totalMinutes: 30 }]);
+ await expect(
+ checkDurationLimits(
+ {
+ PER_DAY: 60,
+ PER_WEEK: 120,
+ },
+ MOCK_DATA.startDate,
+ MOCK_DATA.id
+ )
+ ).resolves.toBeTruthy();
+ });
+ it("Should pass with multiple duration limits with one undefined", async () => {
+ prismaMock.$queryRaw.mockResolvedValue([{ totalMinutes: 30 }]);
+ await expect(
+ checkDurationLimits(
+ {
+ PER_DAY: 60,
+ PER_WEEK: undefined,
+ },
+ MOCK_DATA.startDate,
+ MOCK_DATA.id
+ )
+ ).resolves.toBeTruthy();
+ });
+ it("Should return no errors if limit is not reached with multiple bookings", async () => {
+ prismaMock.$queryRaw.mockResolvedValue([{ totalMinutes: 60 }]);
+ await expect(
+ checkDurationLimits(
+ {
+ PER_DAY: 90,
+ PER_WEEK: 120,
+ },
+ MOCK_DATA.startDate,
+ MOCK_DATA.id
+ )
+ ).resolves.toBeTruthy();
+ });
+ it("Should throw an error if one of the limit is reached with multiple bookings", async () => {
+ prismaMock.$queryRaw.mockResolvedValue([{ totalMinutes: 90 }]);
+ await expect(
+ checkDurationLimits(
+ {
+ PER_DAY: 60,
+ PER_WEEK: 120,
+ },
+ MOCK_DATA.startDate,
+ MOCK_DATA.id
+ )
+ ).rejects.toThrowError();
+ });
+});
+
+// Path: apps/web/test/lib/checkDurationLimits.ts
+describe("Check Duration Limit Tests", () => {
+ it("Should return no busyTimes and no error if limit is not reached", async () => {
+ prismaMock.$queryRaw.mockResolvedValue([{ totalMinutes: 60 }]);
+ await expect(
+ checkDurationLimit({
+ key: "PER_DAY",
+ limitingNumber: 90,
+ eventStartDate: MOCK_DATA.startDate,
+ eventId: MOCK_DATA.id,
+ })
+ ).resolves.toBeUndefined();
+ });
+ it("Should return busyTimes when set and limit is reached", async () => {
+ prismaMock.$queryRaw.mockResolvedValue([{ totalMinutes: 60 }]);
+ await expect(
+ checkDurationLimit({
+ key: "PER_DAY",
+ limitingNumber: 60,
+ eventStartDate: MOCK_DATA.startDate,
+ eventId: MOCK_DATA.id,
+ returnBusyTimes: true,
+ })
+ ).resolves.toEqual({
+ start: dayjs(MOCK_DATA.startDate).startOf("day").toDate(),
+ end: dayjs(MOCK_DATA.startDate).endOf("day").toDate(),
+ });
+ });
+});
+
+describe("Duration limit validation", () => {
+ it("Should validate limit where ranges have ascending values", () => {
+ expect(validateIntervalLimitOrder({ PER_DAY: 30, PER_MONTH: 60 })).toBe(true);
+ });
+ it("Should invalidate limit where ranges does not have a strict ascending values", () => {
+ expect(validateIntervalLimitOrder({ PER_DAY: 60, PER_WEEK: 30 })).toBe(false);
+ });
+ it("Should validate a correct limit with 'gaps'", () => {
+ expect(validateIntervalLimitOrder({ PER_DAY: 60, PER_YEAR: 120 })).toBe(true);
+ });
+ it("Should validate empty limit", () => {
+ expect(validateIntervalLimitOrder({})).toBe(true);
+ });
+});
diff --git a/packages/app-store/routing-forms/DESCRIPTION.md b/packages/app-store/routing-forms/DESCRIPTION.md
index 263aeb21fa..4970fa3f01 100644
--- a/packages/app-store/routing-forms/DESCRIPTION.md
+++ b/packages/app-store/routing-forms/DESCRIPTION.md
@@ -5,4 +5,4 @@ items:
- /api/app-store/routing-forms/3.jpg
---
-It would allow a booker to connect with the right person or choose the right event, faster. It would work by taking inputs from the booker and using that data to route to the correct booker/event as configured by Cal user
\ No newline at end of file
+It would allow a booker to connect with the right person or choose the right event, faster. It would work by taking inputs from the booker and using that data to route to the correct booker/event as configured by Cal user
diff --git a/packages/core/getUserAvailability.ts b/packages/core/getUserAvailability.ts
index 6fcdb041d1..a5a8fdc427 100644
--- a/packages/core/getUserAvailability.ts
+++ b/packages/core/getUserAvailability.ts
@@ -3,15 +3,16 @@ import { z } from "zod";
import type { Dayjs } from "@calcom/dayjs";
import dayjs from "@calcom/dayjs";
-import { parseBookingLimit } from "@calcom/lib";
+import { parseBookingLimit, parseDurationLimit } from "@calcom/lib";
import { getWorkingHours } from "@calcom/lib/availability";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
-import { checkLimit } from "@calcom/lib/server";
+import { checkBookingLimit } from "@calcom/lib/server";
import { performance } from "@calcom/lib/server/perfObserver";
+import { getTotalBookingDuration } from "@calcom/lib/server/queries";
import prisma, { availabilityUserSelect } from "@calcom/prisma";
import { EventTypeMetaDataSchema, stringToDayjs } from "@calcom/prisma/zod-utils";
-import type { BookingLimit, EventBusyDetails } from "@calcom/types/Calendar";
+import type { EventBusyDetails, IntervalLimit } from "@calcom/types/Calendar";
import { getBusyTimes } from "./getBusyTimes";
@@ -24,6 +25,7 @@ const availabilitySchema = z
userId: z.number().optional(),
afterEventBuffer: z.number().optional(),
beforeEventBuffer: z.number().optional(),
+ duration: z.number().optional(),
withSource: z.boolean().optional(),
})
.refine((data) => !!data.username || !!data.userId, "Either username or userId should be filled in.");
@@ -35,6 +37,7 @@ const getEventType = async (id: number) => {
id: true,
seatsPerTimeSlot: true,
bookingLimits: true,
+ durationLimits: true,
timeZone: true,
metadata: true,
schedule: {
@@ -105,6 +108,7 @@ export async function getUserAvailability(
eventTypeId?: number;
afterEventBuffer?: number;
beforeEventBuffer?: number;
+ duration?: number;
},
initialData?: {
user?: User;
@@ -112,7 +116,7 @@ export async function getUserAvailability(
currentSeats?: CurrentSeats;
}
) {
- const { username, userId, dateFrom, dateTo, eventTypeId, afterEventBuffer, beforeEventBuffer } =
+ const { username, userId, dateFrom, dateTo, eventTypeId, afterEventBuffer, beforeEventBuffer, duration } =
availabilitySchema.parse(query);
if (!dateFrom.isValid() || !dateTo.isValid())
@@ -135,8 +139,6 @@ export async function getUserAvailability(
currentSeats = await getCurrentSeats(eventType.id, dateFrom, dateTo);
}
- const bookingLimits = parseBookingLimit(eventType?.bookingLimits);
-
const busyTimes = await getBusyTimes({
credentials: user.credentials,
startTime: dateFrom.toISOString(),
@@ -148,7 +150,7 @@ export async function getUserAvailability(
afterEventBuffer,
});
- const bufferedBusyTimes: EventBusyDetails[] = busyTimes.map((a) => ({
+ let bufferedBusyTimes: EventBusyDetails[] = busyTimes.map((a) => ({
...a,
start: dayjs(a.start).toISOString(),
end: dayjs(a.end).toISOString(),
@@ -156,68 +158,31 @@ export async function getUserAvailability(
source: query.withSource ? a.source : undefined,
}));
+ const bookings = busyTimes.filter((busyTime) => busyTime.source?.startsWith(`eventType-${eventType?.id}`));
+
+ const bookingLimits = parseBookingLimit(eventType?.bookingLimits);
if (bookingLimits) {
- // Get all dates between dateFrom and dateTo
- const dates = []; // this is as dayjs date
- let startDate = dayjs(dateFrom);
- const endDate = dayjs(dateTo);
- while (startDate.isBefore(endDate)) {
- dates.push(startDate);
- startDate = startDate.add(1, "day");
- }
-
- const ourBookings = busyTimes.filter((busyTime) =>
- busyTime.source?.startsWith(`eventType-${eventType?.id}`)
+ const bookingBusyTimes = await getBusyTimesFromBookingLimits(
+ bookings,
+ bookingLimits,
+ dateFrom,
+ dateTo,
+ eventType
);
+ bufferedBusyTimes = bufferedBusyTimes.concat(bookingBusyTimes);
+ }
- // Apply booking limit filter against our bookings
- for (const [key, limit] of Object.entries(bookingLimits)) {
- const limitKey = key as keyof BookingLimit;
-
- if (limitKey === "PER_YEAR") {
- const yearlyBusyTime = await checkLimit({
- eventStartDate: startDate.toDate(),
- limitingNumber: limit,
- eventId: eventType?.id as number,
- key: "PER_YEAR",
- returnBusyTimes: true,
- });
- if (!yearlyBusyTime) break;
- bufferedBusyTimes.push({
- start: yearlyBusyTime.start.toISOString(),
- end: yearlyBusyTime.end.toISOString(),
- });
- break;
- }
- // Take PER_DAY and turn it into day and PER_WEEK into week etc.
- const filter = limitKey.split("_")[1].toLowerCase() as "day" | "week" | "month" | "year";
- // loop through all dates and check if we have reached the limit
- for (const date of dates) {
- let total = 0;
- const startDate = date.startOf(filter);
- // this is parsed above with parseBookingLimit so we know it's safe.
- const endDate = date.endOf(filter);
- for (const booking of ourBookings) {
- const bookingEventTypeId = parseInt(booking.source?.split("-")[1] as string, 10);
- if (
- // Only check OUR booking that matches the current eventTypeId
- // we don't care about another event type in this case as we dont need to know their booking limits
- !(bookingEventTypeId == eventType?.id && dayjs(booking.start).isBetween(startDate, endDate))
- ) {
- continue;
- }
- // increment total and check against the limit, adding a busy time if condition is met.
- total++;
- if (total >= limit) {
- bufferedBusyTimes.push({
- start: startDate.toISOString(),
- end: endDate.toISOString(),
- });
- break;
- }
- }
- }
- }
+ const durationLimits = parseDurationLimit(eventType?.durationLimits);
+ if (durationLimits) {
+ const durationBusyTimes = await getBusyTimesFromDurationLimits(
+ bookings,
+ durationLimits,
+ dateFrom,
+ dateTo,
+ duration,
+ eventType
+ );
+ bufferedBusyTimes = bufferedBusyTimes.concat(durationBusyTimes);
}
const userSchedule = user.schedules.filter(
@@ -264,3 +229,139 @@ export async function getUserAvailability(
currentSeats,
};
}
+
+const getDatesBetween = (dateFrom: Dayjs, dateTo: Dayjs, period: "day" | "week" | "month" | "year") => {
+ const dates = [];
+ let startDate = dayjs(dateFrom).startOf(period);
+ const endDate = dayjs(dateTo).endOf(period);
+ while (startDate.isBefore(endDate)) {
+ dates.push(startDate);
+ startDate = startDate.add(1, period);
+ }
+ return dates;
+};
+
+const getBusyTimesFromBookingLimits = async (
+ bookings: EventBusyDetails[],
+ bookingLimits: IntervalLimit,
+ dateFrom: Dayjs,
+ dateTo: Dayjs,
+ eventType: EventType | undefined
+) => {
+ const busyTimes: EventBusyDetails[] = [];
+
+ // Apply booking limit filter against our bookings
+ for (const [key, limit] of Object.entries(bookingLimits)) {
+ const limitKey = key as keyof IntervalLimit;
+
+ if (limitKey === "PER_YEAR") {
+ const yearlyBusyTime = await checkBookingLimit({
+ eventStartDate: dateFrom.toDate(),
+ limitingNumber: limit,
+ eventId: eventType?.id as number,
+ key: "PER_YEAR",
+ returnBusyTimes: true,
+ });
+ if (!yearlyBusyTime) break;
+ busyTimes.push({
+ start: yearlyBusyTime.start.toISOString(),
+ end: yearlyBusyTime.end.toISOString(),
+ });
+ break;
+ }
+
+ // Take PER_DAY and turn it into day and PER_WEEK into week etc.
+ const filter = key.split("_")[1].toLowerCase() as "day" | "week" | "month" | "year";
+ const dates = getDatesBetween(dateFrom, dateTo, filter);
+
+ // loop through all dates and check if we have reached the limit
+ for (const date of dates) {
+ let total = 0;
+ const startDate = date.startOf(filter);
+ // this is parsed above with parseBookingLimit so we know it's safe.
+ const endDate = date.endOf(filter);
+ for (const booking of bookings) {
+ const bookingEventTypeId = parseInt(booking.source?.split("-")[1] as string, 10);
+ if (
+ // Only check OUR booking that matches the current eventTypeId
+ // we don't care about another event type in this case as we dont need to know their booking limits
+ !(bookingEventTypeId == eventType?.id && dayjs(booking.start).isBetween(startDate, endDate))
+ ) {
+ continue;
+ }
+ // increment total and check against the limit, adding a busy time if condition is met.
+ total++;
+ if (total >= limit) {
+ busyTimes.push({ start: startDate.toISOString(), end: endDate.toISOString() });
+ break;
+ }
+ }
+ }
+ }
+
+ return busyTimes;
+};
+
+const getBusyTimesFromDurationLimits = async (
+ bookings: EventBusyDetails[],
+ durationLimits: IntervalLimit,
+ dateFrom: Dayjs,
+ dateTo: Dayjs,
+ duration: number | undefined,
+ eventType: EventType | undefined
+) => {
+ const busyTimes: EventBusyDetails[] = [];
+ // Start check from larger time periods to smaller time periods, to skip unnecessary checks
+ for (const [key, limit] of Object.entries(durationLimits).reverse()) {
+ // Use aggregate sql query if we are checking PER_YEAR
+ if (key === "PER_YEAR") {
+ const totalBookingDuration = await getTotalBookingDuration({
+ eventId: eventType?.id as number,
+ startDate: dateFrom.startOf("year").toDate(),
+ endDate: dateFrom.endOf("year").toDate(),
+ });
+ if (totalBookingDuration + (duration ?? 0) > limit) {
+ busyTimes.push({
+ start: dateFrom.startOf("year").toISOString(),
+ end: dateFrom.endOf("year").toISOString(),
+ });
+ }
+ continue;
+ }
+
+ const filter = key.split("_")[1].toLowerCase() as "day" | "week" | "month" | "year";
+ const dates = getDatesBetween(dateFrom, dateTo, filter);
+
+ // loop through all dates and check if we have reached the limit
+ for (const date of dates) {
+ let total = duration ?? 0;
+ const startDate = date.startOf(filter);
+ const endDate = date.endOf(filter);
+
+ // add busy time if we have already reached the limit with just the selected duration
+ if (total > limit) {
+ busyTimes.push({ start: startDate.toISOString(), end: endDate.toISOString() });
+ continue;
+ }
+
+ for (const booking of bookings) {
+ const bookingEventTypeId = parseInt(booking.source?.split("-")[1] as string, 10);
+ if (
+ // Only check OUR booking that matches the current eventTypeId
+ // we don't care about another event type in this case as we dont need to know their booking limits
+ !(bookingEventTypeId == eventType?.id && dayjs(booking.start).isBetween(startDate, endDate))
+ ) {
+ continue;
+ }
+ // Add current booking duration to total and check against the limit, adding a busy time if condition is met.
+ total += dayjs(booking.end).diff(dayjs(booking.start), "minute");
+ if (total > limit) {
+ busyTimes.push({ start: startDate.toISOString(), end: endDate.toISOString() });
+ break;
+ }
+ }
+ }
+ }
+
+ return busyTimes;
+};
diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts
index d372ece1b4..bd6a977303 100644
--- a/packages/features/bookings/lib/handleNewBooking.ts
+++ b/packages/features/bookings/lib/handleNewBooking.ts
@@ -43,7 +43,7 @@ import { HttpError } from "@calcom/lib/http-error";
import isOutOfBounds, { BookingDateInPastError } from "@calcom/lib/isOutOfBounds";
import logger from "@calcom/lib/logger";
import { handlePayment } from "@calcom/lib/payment/handlePayment";
-import { checkBookingLimits, getLuckyUser } from "@calcom/lib/server";
+import { checkBookingLimits, checkDurationLimits, getLuckyUser } from "@calcom/lib/server";
import { getTranslation } from "@calcom/lib/server/i18n";
import { slugify } from "@calcom/lib/slugify";
import { updateWebUser as syncServicesUpdateWebUser } from "@calcom/lib/sync/SyncServiceManager";
@@ -222,6 +222,7 @@ const getEventTypesFromDB = async (eventTypeId: number) => {
recurringEvent: true,
seatsShowAttendees: true,
bookingLimits: true,
+ durationLimits: true,
workflows: {
include: {
workflow: {
@@ -592,6 +593,11 @@ async function handler(
await checkBookingLimits(eventType.bookingLimits, startAsDate, eventType.id);
}
+ if (eventType && eventType.hasOwnProperty("durationLimits") && eventType?.durationLimits) {
+ const startAsDate = dayjs(reqBody.start).toDate();
+ await checkDurationLimits(eventType.durationLimits, startAsDate, eventType.id);
+ }
+
if (!eventType.seatsPerTimeSlot) {
const availableUsers = await ensureAvailableUsers(
{
diff --git a/packages/lib/defaultEvents.ts b/packages/lib/defaultEvents.ts
index fc835fed33..a5cfe5ec1b 100644
--- a/packages/lib/defaultEvents.ts
+++ b/packages/lib/defaultEvents.ts
@@ -83,6 +83,7 @@ const commons = {
team: null,
requiresConfirmation: false,
bookingLimits: null,
+ durationLimits: null,
hidden: false,
userId: 0,
owner: null,
diff --git a/packages/lib/getEventTypeById.ts b/packages/lib/getEventTypeById.ts
index bcf4c192ab..7e150e58c7 100644
--- a/packages/lib/getEventTypeById.ts
+++ b/packages/lib/getEventTypeById.ts
@@ -5,7 +5,7 @@ import type { StripeData } from "@calcom/app-store/stripepayment/lib/server";
import { getEventTypeAppData, getLocationGroupedOptions } from "@calcom/app-store/utils";
import type { LocationObject } from "@calcom/core/location";
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
-import { parseBookingLimit, parseRecurringEvent } from "@calcom/lib";
+import { parseBookingLimit, parseDurationLimit, parseRecurringEvent } from "@calcom/lib";
import getEnabledApps from "@calcom/lib/apps/getEnabledApps";
import { CAL_URL } from "@calcom/lib/constants";
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
@@ -99,6 +99,7 @@ export default async function getEventTypeById({
slotInterval: true,
hashedLink: true,
bookingLimits: true,
+ durationLimits: true,
successRedirectUrl: true,
currency: true,
bookingFields: true,
@@ -234,6 +235,7 @@ export default async function getEventTypeById({
schedule: rawEventType.schedule?.id || rawEventType.users[0]?.defaultScheduleId || null,
recurringEvent: parseRecurringEvent(restEventType.recurringEvent),
bookingLimits: parseBookingLimit(restEventType.bookingLimits),
+ durationLimits: parseDurationLimit(restEventType.durationLimits),
locations: locations as unknown as LocationObject[],
metadata: parsedMetaData,
customInputs: parsedCustomInputs,
diff --git a/packages/lib/index.ts b/packages/lib/index.ts
index 2296fb6377..04b47e4d3f 100644
--- a/packages/lib/index.ts
+++ b/packages/lib/index.ts
@@ -2,3 +2,5 @@ export { default as classNames } from "./classNames";
export { default as isPrismaObj, isPrismaObjOrUndefined } from "./isPrismaObj";
export * from "./isRecurringEvent";
export * from "./isBookingLimits";
+export * from "./isDurationLimits";
+export * from "./validateIntervalLimitOrder";
diff --git a/packages/lib/isBookingLimits.ts b/packages/lib/isBookingLimits.ts
index 16d66cfe7e..427a9d1162 100644
--- a/packages/lib/isBookingLimits.ts
+++ b/packages/lib/isBookingLimits.ts
@@ -1,29 +1,12 @@
-import { bookingLimitsType } from "@calcom/prisma/zod-utils";
-import type { BookingLimit } from "@calcom/types/Calendar";
+import { intervalLimitsType } from "@calcom/prisma/zod-utils";
+import type { IntervalLimit } from "@calcom/types/Calendar";
-export function isBookingLimit(obj: unknown): obj is BookingLimit {
- return bookingLimitsType.safeParse(obj).success;
+export function isBookingLimit(obj: unknown): obj is IntervalLimit {
+ return intervalLimitsType.safeParse(obj).success;
}
-export function parseBookingLimit(obj: unknown): BookingLimit | null {
- let bookingLimit: BookingLimit | null = null;
+export function parseBookingLimit(obj: unknown): IntervalLimit | null {
+ let bookingLimit: IntervalLimit | null = null;
if (isBookingLimit(obj)) bookingLimit = obj;
return bookingLimit;
}
-
-export const validateBookingLimitOrder = (input: BookingLimit) => {
- const validationOrderKeys = ["PER_DAY", "PER_WEEK", "PER_MONTH", "PER_YEAR"];
-
- // Sort booking limits by validationOrder
- const sorted = Object.entries(input)
- .sort(([, value], [, valuetwo]) => {
- return value - valuetwo;
- })
- .map(([key]) => key);
-
- const validationOrderWithoutMissing = validationOrderKeys.filter((key) => sorted.includes(key));
-
- const isValid = sorted.every((key, index) => validationOrderWithoutMissing[index] === key);
-
- return isValid;
-};
diff --git a/packages/lib/isDurationLimits.ts b/packages/lib/isDurationLimits.ts
new file mode 100644
index 0000000000..74fb16c47a
--- /dev/null
+++ b/packages/lib/isDurationLimits.ts
@@ -0,0 +1,12 @@
+import { intervalLimitsType } from "@calcom/prisma/zod-utils";
+import type { IntervalLimit } from "@calcom/types/Calendar";
+
+export function isDurationLimit(obj: unknown): obj is IntervalLimit {
+ return intervalLimitsType.safeParse(obj).success;
+}
+
+export function parseDurationLimit(obj: unknown): IntervalLimit | null {
+ let durationLimit: IntervalLimit | null = null;
+ if (isDurationLimit(obj)) durationLimit = obj;
+ return durationLimit;
+}
diff --git a/packages/lib/server/checkBookingLimits.ts b/packages/lib/server/checkBookingLimits.ts
index 386b628567..ccfa31dae9 100644
--- a/packages/lib/server/checkBookingLimits.ts
+++ b/packages/lib/server/checkBookingLimits.ts
@@ -1,6 +1,6 @@
import dayjs from "@calcom/dayjs";
import prisma from "@calcom/prisma";
-import type { BookingLimit } from "@calcom/types/Calendar";
+import type { IntervalLimit } from "@calcom/types/Calendar";
import { HttpError } from "../http-error";
import { parseBookingLimit } from "../isBookingLimits";
@@ -15,7 +15,7 @@ export async function checkBookingLimits(
if (parsedBookingLimits) {
const limitCalculations = Object.entries(parsedBookingLimits).map(
async ([key, limitingNumber]) =>
- await checkLimit({ key, limitingNumber, eventStartDate, eventId, returnBusyTimes })
+ await checkBookingLimit({ key, limitingNumber, eventStartDate, eventId, returnBusyTimes })
);
await Promise.all(limitCalculations)
.then((res) => {
@@ -31,7 +31,7 @@ export async function checkBookingLimits(
return false;
}
-export async function checkLimit({
+export async function checkBookingLimit({
eventStartDate,
eventId,
key,
@@ -45,7 +45,7 @@ export async function checkLimit({
returnBusyTimes?: boolean;
}) {
{
- const limitKey = key as keyof BookingLimit;
+ const limitKey = key as keyof IntervalLimit;
// Take PER_DAY and turn it into day and PER_WEEK into week etc.
const filter = limitKey.split("_")[1].toLocaleLowerCase() as "day" | "week" | "month" | "year"; // Have to cast here
const startDate = dayjs(eventStartDate).startOf(filter).toDate();
@@ -77,7 +77,7 @@ export async function checkLimit({
},
});
if (bookingsInPeriod >= limitingNumber) {
- // This is used when getting availbility
+ // This is used when getting availability
if (returnBusyTimes) {
return {
start: startDate,
diff --git a/packages/lib/server/checkDurationLimits.ts b/packages/lib/server/checkDurationLimits.ts
new file mode 100644
index 0000000000..b234fae729
--- /dev/null
+++ b/packages/lib/server/checkDurationLimits.ts
@@ -0,0 +1,60 @@
+import dayjs from "@calcom/dayjs";
+
+import { HttpError } from "../http-error";
+import { parseDurationLimit } from "../isDurationLimits";
+import { getTotalBookingDuration } from "./queries";
+
+export async function checkDurationLimits(durationLimits: any, eventStartDate: Date, eventId: number) {
+ const parsedDurationLimits = parseDurationLimit(durationLimits);
+ if (!parsedDurationLimits) {
+ return false;
+ }
+
+ const limitCalculations = Object.entries(parsedDurationLimits).map(
+ async ([key, limitingNumber]) =>
+ await checkDurationLimit({ key, limitingNumber, eventStartDate, eventId })
+ );
+
+ await Promise.all(limitCalculations).catch((error) => {
+ throw new HttpError({ message: error.message, statusCode: 401 });
+ });
+
+ return true;
+}
+
+export async function checkDurationLimit({
+ eventStartDate,
+ eventId,
+ key,
+ limitingNumber,
+ returnBusyTimes = false,
+}: {
+ eventStartDate: Date;
+ eventId: number;
+ key: string;
+ limitingNumber: number;
+ returnBusyTimes?: boolean;
+}) {
+ {
+ // Take PER_DAY and turn it into day and PER_WEEK into week etc.
+ const filter = key.split("_")[1].toLocaleLowerCase() as "day" | "week" | "month" | "year";
+ const startDate = dayjs(eventStartDate).startOf(filter).toDate();
+ const endDate = dayjs(startDate).endOf(filter).toDate();
+
+ const totalBookingDuration = await getTotalBookingDuration({ eventId, startDate, endDate });
+ if (totalBookingDuration >= limitingNumber) {
+ // This is used when getting availability
+ if (returnBusyTimes) {
+ return {
+ start: startDate,
+ end: endDate,
+ };
+ }
+
+ throw new HttpError({
+ message: `duration_limit_reached`,
+ statusCode: 403,
+ });
+ }
+ }
+}
diff --git a/packages/lib/server/index.ts b/packages/lib/server/index.ts
index 53253fe2c4..39c8efc9ec 100644
--- a/packages/lib/server/index.ts
+++ b/packages/lib/server/index.ts
@@ -1,4 +1,5 @@
-export { checkBookingLimits, checkLimit } from "./checkBookingLimits";
+export { checkBookingLimits, checkBookingLimit } from "./checkBookingLimits";
+export { checkDurationLimits, checkDurationLimit } from "./checkDurationLimits";
export { defaultHandler } from "./defaultHandler";
export { defaultResponder } from "./defaultResponder";
diff --git a/packages/lib/server/queries/booking/index.ts b/packages/lib/server/queries/booking/index.ts
new file mode 100644
index 0000000000..11ce110fcb
--- /dev/null
+++ b/packages/lib/server/queries/booking/index.ts
@@ -0,0 +1,23 @@
+import prisma from "@calcom/prisma";
+
+export const getTotalBookingDuration = async ({
+ eventId,
+ startDate,
+ endDate,
+}: {
+ eventId: number;
+ startDate: Date;
+ endDate: Date;
+}) => {
+ // Aggregates the total booking time for a given event in a given time period
+ const [totalBookingTime] = (await prisma.$queryRaw`
+ SELECT SUM(EXTRACT(EPOCH FROM ("endTime" - "startTime")) / 60) as "totalMinutes"
+ FROM "Booking"
+ WHERE "status" = 'accepted'
+ AND "id" = ${eventId}
+ AND "startTime" >= ${startDate}
+ AND "endTime" <= ${endDate};
+ `) as { totalMinutes: number }[];
+
+ return totalBookingTime.totalMinutes;
+};
diff --git a/packages/lib/server/queries/index.ts b/packages/lib/server/queries/index.ts
index 9a4630082b..e796b1aadf 100644
--- a/packages/lib/server/queries/index.ts
+++ b/packages/lib/server/queries/index.ts
@@ -1 +1,2 @@
export * from "./teams";
+export * from "./booking";
diff --git a/packages/lib/test/builder.ts b/packages/lib/test/builder.ts
index 9fce24cdfe..4caed9ed8f 100644
--- a/packages/lib/test/builder.ts
+++ b/packages/lib/test/builder.ts
@@ -92,6 +92,7 @@ export const buildEventType = (eventType?: Partial): EventType => {
schedulingType: null,
scheduleId: null,
bookingLimits: null,
+ durationLimits: null,
price: 0,
currency: "usd",
slotInterval: null,
diff --git a/packages/lib/validateIntervalLimitOrder.ts b/packages/lib/validateIntervalLimitOrder.ts
new file mode 100644
index 0000000000..73a066e6c1
--- /dev/null
+++ b/packages/lib/validateIntervalLimitOrder.ts
@@ -0,0 +1,15 @@
+import type { IntervalLimit } from "@calcom/types/Calendar";
+
+const validationOrderKeys = ["PER_DAY", "PER_WEEK", "PER_MONTH", "PER_YEAR"];
+export const validateIntervalLimitOrder = (input: IntervalLimit) => {
+ // Sort limits by validationOrder
+ const sorted = Object.entries(input)
+ .sort(([, value], [, valuetwo]) => {
+ return value - valuetwo;
+ })
+ .map(([key]) => key);
+
+ const validationOrderWithoutMissing = validationOrderKeys.filter((key) => sorted.includes(key));
+
+ return sorted.every((key, index) => validationOrderWithoutMissing[index] === key);
+};
diff --git a/packages/prisma/migrations/20230214083325_add_duration_limits/migration.sql b/packages/prisma/migrations/20230214083325_add_duration_limits/migration.sql
new file mode 100644
index 0000000000..757e0c09c6
--- /dev/null
+++ b/packages/prisma/migrations/20230214083325_add_duration_limits/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "EventType" ADD COLUMN "durationLimits" JSONB;
diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma
index af2ccafbe7..d3bdca4e86 100644
--- a/packages/prisma/schema.prisma
+++ b/packages/prisma/schema.prisma
@@ -95,8 +95,10 @@ model EventType {
/// @zod.custom(imports.successRedirectUrl)
successRedirectUrl String?
workflows WorkflowsOnEventTypes[]
- /// @zod.custom(imports.bookingLimitsType)
+ /// @zod.custom(imports.intervalLimitsType)
bookingLimits Json?
+ /// @zod.custom(imports.intervalLimitsType)
+ durationLimits Json?
@@unique([userId, slug])
@@unique([teamId, slug])
diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts
index 90eb83222e..9a9ab6e742 100644
--- a/packages/prisma/zod-utils.ts
+++ b/packages/prisma/zod-utils.ts
@@ -114,7 +114,7 @@ export const iso8601 = z.string().transform((val, ctx) => {
return d;
});
-export const bookingLimitsType = z
+export const intervalLimitsType = z
.object({
PER_DAY: z.number().optional(),
PER_WEEK: z.number().optional(),
diff --git a/packages/trpc/server/routers/viewer/eventTypes.ts b/packages/trpc/server/routers/viewer/eventTypes.ts
index 7ad40a0974..12531ad6da 100644
--- a/packages/trpc/server/routers/viewer/eventTypes.ts
+++ b/packages/trpc/server/routers/viewer/eventTypes.ts
@@ -9,7 +9,7 @@ import type { LocationObject } from "@calcom/app-store/locations";
import { DailyLocationType } from "@calcom/app-store/locations";
import { stripeDataSchema } from "@calcom/app-store/stripepayment/lib/server";
import getApps, { getAppFromLocationValue, getAppFromSlug } from "@calcom/app-store/utils";
-import { validateBookingLimitOrder } from "@calcom/lib";
+import { validateIntervalLimitOrder } from "@calcom/lib";
import { CAL_URL } from "@calcom/lib/constants";
import getEventTypeById from "@calcom/lib/getEventTypeById";
import { baseEventTypeSelect, baseUserSelect } from "@calcom/prisma";
@@ -528,6 +528,7 @@ export const eventTypesRouter = router({
periodType,
locations,
bookingLimits,
+ durationLimits,
destinationCalendar,
customInputs,
recurringEvent,
@@ -582,12 +583,19 @@ export const eventTypesRouter = router({
}
if (bookingLimits) {
- const isValid = validateBookingLimitOrder(bookingLimits);
+ const isValid = validateIntervalLimitOrder(bookingLimits);
if (!isValid)
throw new TRPCError({ code: "BAD_REQUEST", message: "Booking limits must be in ascending order." });
data.bookingLimits = bookingLimits;
}
+ if (durationLimits) {
+ const isValid = validateIntervalLimitOrder(durationLimits);
+ if (!isValid)
+ throw new TRPCError({ code: "BAD_REQUEST", message: "Duration limits must be in ascending order." });
+ data.durationLimits = durationLimits;
+ }
+
if (schedule) {
// Check that the schedule belongs to the user
const userScheduleQuery = await ctx.prisma.schedule.findFirst({
@@ -769,6 +777,7 @@ export const eventTypesRouter = router({
team,
recurringEvent,
bookingLimits,
+ durationLimits,
metadata,
workflows,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -794,6 +803,7 @@ export const eventTypesRouter = router({
users: users ? { connect: users.map((user) => ({ id: user.id })) } : undefined,
recurringEvent: recurringEvent || undefined,
bookingLimits: bookingLimits ?? undefined,
+ durationLimits: durationLimits ?? undefined,
metadata: metadata === null ? Prisma.DbNull : metadata,
bookingFields: eventType.bookingFields === null ? Prisma.DbNull : eventType.bookingFields,
};
diff --git a/packages/trpc/server/routers/viewer/slots.tsx b/packages/trpc/server/routers/viewer/slots.tsx
index bd8b45136f..9cd3a19fa8 100644
--- a/packages/trpc/server/routers/viewer/slots.tsx
+++ b/packages/trpc/server/routers/viewer/slots.tsx
@@ -125,6 +125,7 @@ async function getEventType(ctx: { prisma: typeof prisma }, input: z.infer, ctx:
eventTypeId: input.eventTypeId,
afterEventBuffer: eventType.afterEventBuffer,
beforeEventBuffer: eventType.beforeEventBuffer,
+ duration: input.duration || 0,
},
{ user: currentUser, eventType, currentSeats }
);
diff --git a/packages/types/Calendar.d.ts b/packages/types/Calendar.d.ts
index 46e94dc7dd..b6f6f331a8 100644
--- a/packages/types/Calendar.d.ts
+++ b/packages/types/Calendar.d.ts
@@ -4,6 +4,7 @@ import type { calendar_v3 } from "googleapis";
import type { Time } from "ical.js";
import type { TFunction } from "next-i18next";
+import type { Calendar } from "@calcom/features/calendars/weeklyview";
import type { Frequency } from "@calcom/prisma/zod-utils";
import type { Ensure } from "./utils";
@@ -115,7 +116,7 @@ export interface RecurringEvent {
tzid?: string | undefined;
}
-export interface BookingLimit {
+export interface IntervalLimit {
PER_DAY?: number | undefined;
PER_WEEK?: number | undefined;
PER_MONTH?: number | undefined;