[CAL-988] Limit total appointment time per day/week/month/year (#7166)

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Omar López <zomars@me.com>
This commit is contained in:
Shane Maglangit 2023-03-11 04:00:19 +08:00 committed by GitHub
parent eb66931366
commit cbc9cd60d5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 664 additions and 228 deletions

View File

@ -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. - 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
<table> <table>
<tr> <tr>
@ -57,7 +57,6 @@ Contributions are what make the open source community such an amazing place to l
</tr> </tr>
</table> </table>
## Developing ## Developing
The development branch is `main`. This is the branch that all pull The development branch is `main`. This is the branch that all pull

View File

@ -350,17 +350,16 @@ We have a list of [help wanted](https://github.com/calcom/cal.com/issues?q=is:is
<img alt="Bounties of cal" src="https://console.algora.io/api/og/cal/bounties.png?p=0&status=open&theme=light"> <img alt="Bounties of cal" src="https://console.algora.io/api/og/cal/bounties.png?p=0&status=open&theme=light">
</picture> </picture>
</a> </a>
<!-- CONTRIBUTORS --> <!-- CONTRIBUTORS -->
### Contributors ### Contributors
<a href="https://github.com/calcom/cal.com/graphs/contributors"> <a href="https://github.com/calcom/cal.com/graphs/contributors">
<img src="https://contrib.rocks/image?repo=calcom/cal.com" /> <img src="https://contrib.rocks/image?repo=calcom/cal.com" />
</a> </a>
<!-- TRANSLATIONS --> <!-- TRANSLATIONS -->
### Translations ### Translations

View File

@ -1,9 +1,11 @@
import { useAutoAnimate } from "@formkit/auto-animate/react"; import { useAutoAnimate } from "@formkit/auto-animate/react";
import * as RadioGroup from "@radix-ui/react-radio-group"; import * as RadioGroup from "@radix-ui/react-radio-group";
import type { EventTypeSetupProps, FormValues } from "pages/event-types/[type]"; import type { EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
import type { Key } from "react";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import type { UseFormRegisterReturn } from "react-hook-form"; import type { UseFormRegisterReturn } from "react-hook-form";
import { Controller, useFormContext, useWatch } from "react-hook-form"; import { Controller, useFormContext, useWatch } from "react-hook-form";
import type { SingleValue } from "react-select";
import { classNames } from "@calcom/lib"; import { classNames } from "@calcom/lib";
import type { DurationType } from "@calcom/lib/convertToNewDurationType"; import type { DurationType } from "@calcom/lib/convertToNewDurationType";
@ -11,8 +13,17 @@ import convertToNewDurationType from "@calcom/lib/convertToNewDurationType";
import findDurationType from "@calcom/lib/findDurationType"; import findDurationType from "@calcom/lib/findDurationType";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { PeriodType } from "@calcom/prisma/client"; import type { PeriodType } from "@calcom/prisma/client";
import type { BookingLimit } from "@calcom/types/Calendar"; import type { IntervalLimit } from "@calcom/types/Calendar";
import { Button, DateRangePicker, Input, InputField, Label, Select, SettingsToggle } from "@calcom/ui"; import {
Button,
DateRangePicker,
Input,
InputField,
Label,
Select,
SettingsToggle,
TextField,
} from "@calcom/ui";
import { FiPlus, FiTrash } from "@calcom/ui/components/icon"; import { FiPlus, FiTrash } from "@calcom/ui/components/icon";
const MinimumBookingNoticeInput = React.forwardRef< const MinimumBookingNoticeInput = React.forwardRef<
@ -260,7 +271,34 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
formMethods.setValue("bookingLimits", {}); formMethods.setValue("bookingLimits", {});
} }
}}> }}>
<BookingLimits /> <IntervalLimitsManager propertyName="bookingLimits" defaultLimit={1} step={1} />
</SettingsToggle>
)}
/>
<hr />
<Controller
name="durationLimits"
control={formMethods.control}
render={({ field: { value } }) => (
<SettingsToggle
title={t("limit_total_booking_duration")}
description={t("limit_total_booking_duration_description")}
checked={Object.keys(value ?? {}).length > 0}
onCheckedChange={(active) => {
if (active) {
formMethods.setValue("durationLimits", {
PER_DAY: 60,
});
} else {
formMethods.setValue("durationLimits", {});
}
}}>
<IntervalLimitsManager
propertyName="durationLimits"
defaultLimit={60}
step={15}
textFieldSuffix={t("minutes")}
/>
</SettingsToggle> </SettingsToggle>
)} )}
/> />
@ -348,124 +386,158 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
); );
}; };
const validationOrderKeys = ["PER_DAY", "PER_WEEK", "PER_MONTH", "PER_YEAR"]; type IntervalLimitsKey = keyof IntervalLimit;
type BookingLimitsKey = keyof BookingLimit;
const BookingLimits = () => { 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 (
<div className="mb-2 flex items-center space-x-2 text-sm rtl:space-x-reverse" key={limitKey}>
<TextField
required
type="number"
containerClassName={`${textFieldSuffix ? "w-36" : "w-16"} -mb-1`}
placeholder={`${value}`}
min={step}
step={step}
defaultValue={value}
addOnSuffix={textFieldSuffix}
onChange={(e) => onLimitChange(limitKey, parseInt(e.target.value))}
/>
<Select
options={selectOptions}
isSearchable={false}
defaultValue={INTERVAL_LIMIT_OPTIONS.find((option) => option.value === limitKey)}
onChange={onIntervalSelect}
/>
{hasDeleteButton && (
<Button variant="icon" StartIcon={FiTrash} color="destructive" onClick={() => onDelete(limitKey)} />
)}
</div>
);
};
type IntervalLimitsManagerProps<K extends "durationLimits" | "bookingLimits"> = {
propertyName: K;
defaultLimit: number;
step: number;
textFieldSuffix?: string;
};
const IntervalLimitsManager = <K extends "durationLimits" | "bookingLimits">({
propertyName,
defaultLimit,
step,
textFieldSuffix,
}: IntervalLimitsManagerProps<K>) => {
const { watch, setValue, control } = useFormContext<FormValues>(); const { watch, setValue, control } = useFormContext<FormValues>();
const watchBookingLimits = watch("bookingLimits"); const watchIntervalLimits = watch(propertyName);
const { t } = useLocale(); const { t } = useLocale();
const [animateRef] = useAutoAnimate<HTMLUListElement>(); const [animateRef] = useAutoAnimate<HTMLUListElement>();
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 ( return (
<Controller <Controller
name="bookingLimits" name={propertyName}
control={control} control={control}
render={({ field: { value, onChange } }) => { render={({ field: { value, onChange } }) => {
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 ( return (
<ul ref={animateRef}> <ul ref={animateRef}>
{currentBookingLimits && {currentIntervalLimits &&
watchBookingLimits && watchIntervalLimits &&
Object.entries(currentBookingLimits) Object.entries(currentIntervalLimits)
.sort(([limitkeyA], [limitKeyB]) => { .sort(([limitKeyA], [limitKeyB]) => {
return ( return (
validationOrderKeys.indexOf(limitkeyA as BookingLimitsKey) - intervalOrderKeys.indexOf(limitKeyA as IntervalLimitsKey) -
validationOrderKeys.indexOf(limitKeyB as BookingLimitsKey) intervalOrderKeys.indexOf(limitKeyB as IntervalLimitsKey)
); );
}) })
.map(([key, bookingAmount]) => { .map(([key, value]) => {
const bookingLimitKey = key as BookingLimitsKey; const limitKey = key as IntervalLimitsKey;
return ( return (
<div <IntervalLimitItem
className="mb-2 flex items-center space-x-2 text-sm rtl:space-x-reverse" key={key}
key={bookingLimitKey}> limitKey={limitKey}
<Input step={step}
id={`${bookingLimitKey}-limit`} value={value}
type="number" textFieldSuffix={textFieldSuffix}
className="mb-0 block w-16 rounded-md border-gray-300 text-sm [appearance:textfield]" hasDeleteButton={Object.keys(currentIntervalLimits).length > 1}
placeholder="1" selectOptions={INTERVAL_LIMIT_OPTIONS.filter(
min={1} (option) => !Object.keys(currentIntervalLimits).includes(option.value)
defaultValue={bookingAmount} )}
onChange={(e) => { onLimitChange={(intervalLimitKey, val) =>
const val = e.target.value; // @ts-expect-error FIXME Fix these typings
setValue(`bookingLimits.${bookingLimitKey}`, parseInt(val)); setValue(`${propertyName}.${intervalLimitKey}`, val)
}} }
/> onDelete={(intervalLimitKey) => {
<Select const current = currentIntervalLimits;
options={BOOKING_LIMIT_OPTIONS.filter( delete current[intervalLimitKey];
(option) => !Object.keys(currentBookingLimits).includes(option.value) onChange(current);
)} }}
isSearchable={false} onIntervalSelect={(interval) => {
defaultValue={BOOKING_LIMIT_OPTIONS.find((option) => option.value === key)} const current = currentIntervalLimits;
onChange={(val) => { const currentValue = watchIntervalLimits[limitKey];
const current = currentBookingLimits;
const currentValue = watchBookingLimits[bookingLimitKey];
// Removes limit from previous selected value (eg when changed from per_week to per_month, we unset per_week here) // Removes limit from previous selected value (eg when changed from per_week to per_month, we unset per_week here)
delete current[bookingLimitKey]; delete current[limitKey];
const newData = { const newData = {
...current, ...current,
// Set limit to new selected value (in the example above this means we set the limit to per_week here). // 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, [interval?.value as IntervalLimitsKey]: currentValue,
}; };
onChange(newData); onChange(newData);
}} }}
/> />
<Button
variant="icon"
StartIcon={FiTrash}
color="destructive"
onClick={() => {
const current = currentBookingLimits;
delete current[key as BookingLimitsKey];
onChange(current);
}}
/>
</div>
); );
})} })}
{currentBookingLimits && Object.keys(currentBookingLimits).length <= 3 && ( {currentIntervalLimits && Object.keys(currentIntervalLimits).length <= 3 && (
<Button <Button color="minimal" StartIcon={FiPlus} onClick={addLimit}>
color="minimal"
StartIcon={FiPlus}
onClick={() => {
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,
});
}}>
{t("add_limit")} {t("add_limit")}
</Button> </Button>
)} )}

View File

@ -10,7 +10,7 @@ import { z } from "zod";
import { validateCustomEventName } from "@calcom/core/event"; import { validateCustomEventName } from "@calcom/core/event";
import type { EventLocationType } from "@calcom/core/location"; 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 { CAL_URL } from "@calcom/lib/constants";
import getEventTypeById from "@calcom/lib/getEventTypeById"; import getEventTypeById from "@calcom/lib/getEventTypeById";
import { useLocale } from "@calcom/lib/hooks/useLocale"; 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 { customInputSchema, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import type { RouterOutputs } from "@calcom/trpc/react"; import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } 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 { Form, showToast } from "@calcom/ui";
import { asStringOrThrow } from "@lib/asStringOrNull"; import { asStringOrThrow } from "@lib/asStringOrNull";
@ -86,7 +86,8 @@ export type FormValues = {
externalId: string; externalId: string;
}; };
successRedirectUrl: string; successRedirectUrl: string;
bookingLimits?: BookingLimit; durationLimits?: IntervalLimit;
bookingLimits?: IntervalLimit;
hosts: { userId: number; isFixed: boolean }[]; hosts: { userId: number; isFixed: boolean }[];
bookingFields: z.infer<typeof eventTypeBookingFields>; bookingFields: z.infer<typeof eventTypeBookingFields>;
}; };
@ -194,6 +195,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
description: eventType.description ?? undefined, description: eventType.description ?? undefined,
schedule: eventType.schedule || undefined, schedule: eventType.schedule || undefined,
bookingLimits: eventType.bookingLimits || undefined, bookingLimits: eventType.bookingLimits || undefined,
durationLimits: eventType.durationLimits || undefined,
length: eventType.length, length: eventType.length,
hidden: eventType.hidden, hidden: eventType.hidden,
periodDates: { periodDates: {
@ -303,6 +305,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
seatsPerTimeSlot, seatsPerTimeSlot,
seatsShowAttendees, seatsShowAttendees,
bookingLimits, bookingLimits,
durationLimits,
recurringEvent, recurringEvent,
locations, locations,
metadata, metadata,
@ -316,10 +319,15 @@ const EventTypePage = (props: EventTypeSetupProps) => {
} = values; } = values;
if (bookingLimits) { if (bookingLimits) {
const isValid = validateBookingLimitOrder(bookingLimits); const isValid = validateIntervalLimitOrder(bookingLimits);
if (!isValid) throw new Error(t("event_setup_booking_limits_error")); 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 !== undefined) {
if (metadata?.multipleDuration.length < 1) { if (metadata?.multipleDuration.length < 1) {
throw new Error(t("event_setup_multiple_duration_error")); throw new Error(t("event_setup_multiple_duration_error"));
@ -341,6 +349,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
beforeEventBuffer: beforeBufferTime, beforeEventBuffer: beforeBufferTime,
afterEventBuffer: afterBufferTime, afterEventBuffer: afterBufferTime,
bookingLimits, bookingLimits,
durationLimits,
seatsPerTimeSlot, seatsPerTimeSlot,
seatsShowAttendees, seatsShowAttendees,
metadata, metadata,

View File

@ -1156,7 +1156,8 @@
"event_advanced_tab_title": "Advanced", "event_advanced_tab_title": "Advanced",
"event_setup_multiple_duration_error": "Event Setup: Multiple durations requires at least 1 option.", "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_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", "select_which_cal": "Select which calendar to add bookings to",
"custom_event_name": "Custom event name", "custom_event_name": "Custom event name",
"custom_event_name_description": "Create customised event names to display on calendar event", "custom_event_name_description": "Create customised event names to display on calendar event",
@ -1340,6 +1341,8 @@
"report_app": "Report app", "report_app": "Report app",
"limit_booking_frequency": "Limit booking frequency", "limit_booking_frequency": "Limit booking frequency",
"limit_booking_frequency_description": "Limit how many times this event can be booked", "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", "add_limit": "Add Limit",
"team_name_required": "Team name required", "team_name_required": "Team name required",
"show_attendees": "Share attendee information between guests", "show_attendees": "Share attendee information between guests",
@ -1443,6 +1446,7 @@
"calcom_is_better_with_team": "Cal.com is better with teams", "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.", "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", "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}}", "admin_has_disabled": "An admin has disabled {{appName}}",
"disabled_app_affects_event_type": "An admin has disabled {{appName}} which affects your event type {{eventType}}", "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.", "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.",

View File

@ -1,14 +1,14 @@
import dayjs from "@calcom/dayjs"; import dayjs from "@calcom/dayjs";
import { validateBookingLimitOrder } from "@calcom/lib/isBookingLimits"; import { validateIntervalLimitOrder } from "@calcom/lib";
import { checkBookingLimits, checkLimit } from "@calcom/lib/server"; import { checkBookingLimits, checkBookingLimit } from "@calcom/lib/server";
import type { BookingLimit } from "@calcom/types/Calendar"; import type { IntervalLimit } from "@calcom/types/Calendar";
import { prismaMock } from "../../../../tests/config/singleton"; import { prismaMock } from "../../../../tests/config/singleton";
type Mockdata = { type Mockdata = {
id: number; id: number;
startDate: Date; startDate: Date;
bookingLimits: BookingLimit; bookingLimits: IntervalLimit;
}; };
const MOCK_DATA: Mockdata = { const MOCK_DATA: Mockdata = {
@ -63,7 +63,7 @@ describe("Check Booking Limits Tests", () => {
it("Should handle mutiple limits correctly", async () => { it("Should handle mutiple limits correctly", async () => {
prismaMock.booking.count.mockResolvedValue(1); prismaMock.booking.count.mockResolvedValue(1);
expect( expect(
checkLimit({ checkBookingLimit({
key: "PER_DAY", key: "PER_DAY",
limitingNumber: 2, limitingNumber: 2,
eventStartDate: MOCK_DATA.startDate, eventStartDate: MOCK_DATA.startDate,
@ -72,7 +72,7 @@ describe("Check Booking Limits Tests", () => {
).resolves.not.toThrow(); ).resolves.not.toThrow();
prismaMock.booking.count.mockResolvedValue(3); prismaMock.booking.count.mockResolvedValue(3);
expect( expect(
checkLimit({ checkBookingLimit({
key: "PER_WEEK", key: "PER_WEEK",
limitingNumber: 2, limitingNumber: 2,
eventStartDate: MOCK_DATA.startDate, eventStartDate: MOCK_DATA.startDate,
@ -83,7 +83,7 @@ describe("Check Booking Limits Tests", () => {
it("Should return busyTimes when set", async () => { it("Should return busyTimes when set", async () => {
prismaMock.booking.count.mockResolvedValue(2); prismaMock.booking.count.mockResolvedValue(2);
expect( expect(
checkLimit({ checkBookingLimit({
key: "PER_DAY", key: "PER_DAY",
limitingNumber: 2, limitingNumber: 2,
eventStartDate: MOCK_DATA.startDate, eventStartDate: MOCK_DATA.startDate,
@ -99,21 +99,21 @@ describe("Check Booking Limits Tests", () => {
describe("Booking limit validation", () => { describe("Booking limit validation", () => {
it("Should validate a correct limit", () => { 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", () => { 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' ", () => { 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 ", () => { 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", () => { it("Should validate a correct with empty", () => {
expect(validateBookingLimitOrder({})).toBe(true); expect(validateIntervalLimitOrder({})).toBe(true);
}); });
}); });

View File

@ -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);
});
});

View File

@ -5,4 +5,4 @@ items:
- /api/app-store/routing-forms/3.jpg - /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 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

View File

@ -3,15 +3,16 @@ import { z } from "zod";
import type { Dayjs } from "@calcom/dayjs"; import type { Dayjs } from "@calcom/dayjs";
import 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 { getWorkingHours } from "@calcom/lib/availability";
import { HttpError } from "@calcom/lib/http-error"; import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger"; 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 { performance } from "@calcom/lib/server/perfObserver";
import { getTotalBookingDuration } from "@calcom/lib/server/queries";
import prisma, { availabilityUserSelect } from "@calcom/prisma"; import prisma, { availabilityUserSelect } from "@calcom/prisma";
import { EventTypeMetaDataSchema, stringToDayjs } from "@calcom/prisma/zod-utils"; 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"; import { getBusyTimes } from "./getBusyTimes";
@ -24,6 +25,7 @@ const availabilitySchema = z
userId: z.number().optional(), userId: z.number().optional(),
afterEventBuffer: z.number().optional(), afterEventBuffer: z.number().optional(),
beforeEventBuffer: z.number().optional(), beforeEventBuffer: z.number().optional(),
duration: z.number().optional(),
withSource: z.boolean().optional(), withSource: z.boolean().optional(),
}) })
.refine((data) => !!data.username || !!data.userId, "Either username or userId should be filled in."); .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, id: true,
seatsPerTimeSlot: true, seatsPerTimeSlot: true,
bookingLimits: true, bookingLimits: true,
durationLimits: true,
timeZone: true, timeZone: true,
metadata: true, metadata: true,
schedule: { schedule: {
@ -105,6 +108,7 @@ export async function getUserAvailability(
eventTypeId?: number; eventTypeId?: number;
afterEventBuffer?: number; afterEventBuffer?: number;
beforeEventBuffer?: number; beforeEventBuffer?: number;
duration?: number;
}, },
initialData?: { initialData?: {
user?: User; user?: User;
@ -112,7 +116,7 @@ export async function getUserAvailability(
currentSeats?: CurrentSeats; currentSeats?: CurrentSeats;
} }
) { ) {
const { username, userId, dateFrom, dateTo, eventTypeId, afterEventBuffer, beforeEventBuffer } = const { username, userId, dateFrom, dateTo, eventTypeId, afterEventBuffer, beforeEventBuffer, duration } =
availabilitySchema.parse(query); availabilitySchema.parse(query);
if (!dateFrom.isValid() || !dateTo.isValid()) if (!dateFrom.isValid() || !dateTo.isValid())
@ -135,8 +139,6 @@ export async function getUserAvailability(
currentSeats = await getCurrentSeats(eventType.id, dateFrom, dateTo); currentSeats = await getCurrentSeats(eventType.id, dateFrom, dateTo);
} }
const bookingLimits = parseBookingLimit(eventType?.bookingLimits);
const busyTimes = await getBusyTimes({ const busyTimes = await getBusyTimes({
credentials: user.credentials, credentials: user.credentials,
startTime: dateFrom.toISOString(), startTime: dateFrom.toISOString(),
@ -148,7 +150,7 @@ export async function getUserAvailability(
afterEventBuffer, afterEventBuffer,
}); });
const bufferedBusyTimes: EventBusyDetails[] = busyTimes.map((a) => ({ let bufferedBusyTimes: EventBusyDetails[] = busyTimes.map((a) => ({
...a, ...a,
start: dayjs(a.start).toISOString(), start: dayjs(a.start).toISOString(),
end: dayjs(a.end).toISOString(), end: dayjs(a.end).toISOString(),
@ -156,68 +158,31 @@ export async function getUserAvailability(
source: query.withSource ? a.source : undefined, source: query.withSource ? a.source : undefined,
})); }));
const bookings = busyTimes.filter((busyTime) => busyTime.source?.startsWith(`eventType-${eventType?.id}`));
const bookingLimits = parseBookingLimit(eventType?.bookingLimits);
if (bookingLimits) { if (bookingLimits) {
// Get all dates between dateFrom and dateTo const bookingBusyTimes = await getBusyTimesFromBookingLimits(
const dates = []; // this is as dayjs date bookings,
let startDate = dayjs(dateFrom); bookingLimits,
const endDate = dayjs(dateTo); dateFrom,
while (startDate.isBefore(endDate)) { dateTo,
dates.push(startDate); eventType
startDate = startDate.add(1, "day");
}
const ourBookings = busyTimes.filter((busyTime) =>
busyTime.source?.startsWith(`eventType-${eventType?.id}`)
); );
bufferedBusyTimes = bufferedBusyTimes.concat(bookingBusyTimes);
}
// Apply booking limit filter against our bookings const durationLimits = parseDurationLimit(eventType?.durationLimits);
for (const [key, limit] of Object.entries(bookingLimits)) { if (durationLimits) {
const limitKey = key as keyof BookingLimit; const durationBusyTimes = await getBusyTimesFromDurationLimits(
bookings,
if (limitKey === "PER_YEAR") { durationLimits,
const yearlyBusyTime = await checkLimit({ dateFrom,
eventStartDate: startDate.toDate(), dateTo,
limitingNumber: limit, duration,
eventId: eventType?.id as number, eventType
key: "PER_YEAR", );
returnBusyTimes: true, bufferedBusyTimes = bufferedBusyTimes.concat(durationBusyTimes);
});
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 userSchedule = user.schedules.filter( const userSchedule = user.schedules.filter(
@ -264,3 +229,139 @@ export async function getUserAvailability(
currentSeats, 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;
};

View File

@ -43,7 +43,7 @@ import { HttpError } from "@calcom/lib/http-error";
import isOutOfBounds, { BookingDateInPastError } from "@calcom/lib/isOutOfBounds"; import isOutOfBounds, { BookingDateInPastError } from "@calcom/lib/isOutOfBounds";
import logger from "@calcom/lib/logger"; import logger from "@calcom/lib/logger";
import { handlePayment } from "@calcom/lib/payment/handlePayment"; 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 { getTranslation } from "@calcom/lib/server/i18n";
import { slugify } from "@calcom/lib/slugify"; import { slugify } from "@calcom/lib/slugify";
import { updateWebUser as syncServicesUpdateWebUser } from "@calcom/lib/sync/SyncServiceManager"; import { updateWebUser as syncServicesUpdateWebUser } from "@calcom/lib/sync/SyncServiceManager";
@ -222,6 +222,7 @@ const getEventTypesFromDB = async (eventTypeId: number) => {
recurringEvent: true, recurringEvent: true,
seatsShowAttendees: true, seatsShowAttendees: true,
bookingLimits: true, bookingLimits: true,
durationLimits: true,
workflows: { workflows: {
include: { include: {
workflow: { workflow: {
@ -592,6 +593,11 @@ async function handler(
await checkBookingLimits(eventType.bookingLimits, startAsDate, eventType.id); 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) { if (!eventType.seatsPerTimeSlot) {
const availableUsers = await ensureAvailableUsers( const availableUsers = await ensureAvailableUsers(
{ {

View File

@ -83,6 +83,7 @@ const commons = {
team: null, team: null,
requiresConfirmation: false, requiresConfirmation: false,
bookingLimits: null, bookingLimits: null,
durationLimits: null,
hidden: false, hidden: false,
userId: 0, userId: 0,
owner: null, owner: null,

View File

@ -5,7 +5,7 @@ import type { StripeData } from "@calcom/app-store/stripepayment/lib/server";
import { getEventTypeAppData, getLocationGroupedOptions } from "@calcom/app-store/utils"; import { getEventTypeAppData, getLocationGroupedOptions } from "@calcom/app-store/utils";
import type { LocationObject } from "@calcom/core/location"; import type { LocationObject } from "@calcom/core/location";
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields"; 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 getEnabledApps from "@calcom/lib/apps/getEnabledApps";
import { CAL_URL } from "@calcom/lib/constants"; import { CAL_URL } from "@calcom/lib/constants";
import getPaymentAppData from "@calcom/lib/getPaymentAppData"; import getPaymentAppData from "@calcom/lib/getPaymentAppData";
@ -99,6 +99,7 @@ export default async function getEventTypeById({
slotInterval: true, slotInterval: true,
hashedLink: true, hashedLink: true,
bookingLimits: true, bookingLimits: true,
durationLimits: true,
successRedirectUrl: true, successRedirectUrl: true,
currency: true, currency: true,
bookingFields: true, bookingFields: true,
@ -234,6 +235,7 @@ export default async function getEventTypeById({
schedule: rawEventType.schedule?.id || rawEventType.users[0]?.defaultScheduleId || null, schedule: rawEventType.schedule?.id || rawEventType.users[0]?.defaultScheduleId || null,
recurringEvent: parseRecurringEvent(restEventType.recurringEvent), recurringEvent: parseRecurringEvent(restEventType.recurringEvent),
bookingLimits: parseBookingLimit(restEventType.bookingLimits), bookingLimits: parseBookingLimit(restEventType.bookingLimits),
durationLimits: parseDurationLimit(restEventType.durationLimits),
locations: locations as unknown as LocationObject[], locations: locations as unknown as LocationObject[],
metadata: parsedMetaData, metadata: parsedMetaData,
customInputs: parsedCustomInputs, customInputs: parsedCustomInputs,

View File

@ -2,3 +2,5 @@ export { default as classNames } from "./classNames";
export { default as isPrismaObj, isPrismaObjOrUndefined } from "./isPrismaObj"; export { default as isPrismaObj, isPrismaObjOrUndefined } from "./isPrismaObj";
export * from "./isRecurringEvent"; export * from "./isRecurringEvent";
export * from "./isBookingLimits"; export * from "./isBookingLimits";
export * from "./isDurationLimits";
export * from "./validateIntervalLimitOrder";

View File

@ -1,29 +1,12 @@
import { bookingLimitsType } from "@calcom/prisma/zod-utils"; import { intervalLimitsType } from "@calcom/prisma/zod-utils";
import type { BookingLimit } from "@calcom/types/Calendar"; import type { IntervalLimit } from "@calcom/types/Calendar";
export function isBookingLimit(obj: unknown): obj is BookingLimit { export function isBookingLimit(obj: unknown): obj is IntervalLimit {
return bookingLimitsType.safeParse(obj).success; return intervalLimitsType.safeParse(obj).success;
} }
export function parseBookingLimit(obj: unknown): BookingLimit | null { export function parseBookingLimit(obj: unknown): IntervalLimit | null {
let bookingLimit: BookingLimit | null = null; let bookingLimit: IntervalLimit | null = null;
if (isBookingLimit(obj)) bookingLimit = obj; if (isBookingLimit(obj)) bookingLimit = obj;
return bookingLimit; 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;
};

View File

@ -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;
}

View File

@ -1,6 +1,6 @@
import dayjs from "@calcom/dayjs"; import dayjs from "@calcom/dayjs";
import prisma from "@calcom/prisma"; 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 { HttpError } from "../http-error";
import { parseBookingLimit } from "../isBookingLimits"; import { parseBookingLimit } from "../isBookingLimits";
@ -15,7 +15,7 @@ export async function checkBookingLimits(
if (parsedBookingLimits) { if (parsedBookingLimits) {
const limitCalculations = Object.entries(parsedBookingLimits).map( const limitCalculations = Object.entries(parsedBookingLimits).map(
async ([key, limitingNumber]) => async ([key, limitingNumber]) =>
await checkLimit({ key, limitingNumber, eventStartDate, eventId, returnBusyTimes }) await checkBookingLimit({ key, limitingNumber, eventStartDate, eventId, returnBusyTimes })
); );
await Promise.all(limitCalculations) await Promise.all(limitCalculations)
.then((res) => { .then((res) => {
@ -31,7 +31,7 @@ export async function checkBookingLimits(
return false; return false;
} }
export async function checkLimit({ export async function checkBookingLimit({
eventStartDate, eventStartDate,
eventId, eventId,
key, key,
@ -45,7 +45,7 @@ export async function checkLimit({
returnBusyTimes?: boolean; 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. // 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 filter = limitKey.split("_")[1].toLocaleLowerCase() as "day" | "week" | "month" | "year"; // Have to cast here
const startDate = dayjs(eventStartDate).startOf(filter).toDate(); const startDate = dayjs(eventStartDate).startOf(filter).toDate();
@ -77,7 +77,7 @@ export async function checkLimit({
}, },
}); });
if (bookingsInPeriod >= limitingNumber) { if (bookingsInPeriod >= limitingNumber) {
// This is used when getting availbility // This is used when getting availability
if (returnBusyTimes) { if (returnBusyTimes) {
return { return {
start: startDate, start: startDate,

View File

@ -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,
});
}
}
}

View File

@ -1,4 +1,5 @@
export { checkBookingLimits, checkLimit } from "./checkBookingLimits"; export { checkBookingLimits, checkBookingLimit } from "./checkBookingLimits";
export { checkDurationLimits, checkDurationLimit } from "./checkDurationLimits";
export { defaultHandler } from "./defaultHandler"; export { defaultHandler } from "./defaultHandler";
export { defaultResponder } from "./defaultResponder"; export { defaultResponder } from "./defaultResponder";

View File

@ -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;
};

View File

@ -1 +1,2 @@
export * from "./teams"; export * from "./teams";
export * from "./booking";

View File

@ -92,6 +92,7 @@ export const buildEventType = (eventType?: Partial<EventType>): EventType => {
schedulingType: null, schedulingType: null,
scheduleId: null, scheduleId: null,
bookingLimits: null, bookingLimits: null,
durationLimits: null,
price: 0, price: 0,
currency: "usd", currency: "usd",
slotInterval: null, slotInterval: null,

View File

@ -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);
};

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "EventType" ADD COLUMN "durationLimits" JSONB;

View File

@ -95,8 +95,10 @@ model EventType {
/// @zod.custom(imports.successRedirectUrl) /// @zod.custom(imports.successRedirectUrl)
successRedirectUrl String? successRedirectUrl String?
workflows WorkflowsOnEventTypes[] workflows WorkflowsOnEventTypes[]
/// @zod.custom(imports.bookingLimitsType) /// @zod.custom(imports.intervalLimitsType)
bookingLimits Json? bookingLimits Json?
/// @zod.custom(imports.intervalLimitsType)
durationLimits Json?
@@unique([userId, slug]) @@unique([userId, slug])
@@unique([teamId, slug]) @@unique([teamId, slug])

View File

@ -114,7 +114,7 @@ export const iso8601 = z.string().transform((val, ctx) => {
return d; return d;
}); });
export const bookingLimitsType = z export const intervalLimitsType = z
.object({ .object({
PER_DAY: z.number().optional(), PER_DAY: z.number().optional(),
PER_WEEK: z.number().optional(), PER_WEEK: z.number().optional(),

View File

@ -9,7 +9,7 @@ import type { LocationObject } from "@calcom/app-store/locations";
import { DailyLocationType } from "@calcom/app-store/locations"; import { DailyLocationType } from "@calcom/app-store/locations";
import { stripeDataSchema } from "@calcom/app-store/stripepayment/lib/server"; import { stripeDataSchema } from "@calcom/app-store/stripepayment/lib/server";
import getApps, { getAppFromLocationValue, getAppFromSlug } from "@calcom/app-store/utils"; 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 { CAL_URL } from "@calcom/lib/constants";
import getEventTypeById from "@calcom/lib/getEventTypeById"; import getEventTypeById from "@calcom/lib/getEventTypeById";
import { baseEventTypeSelect, baseUserSelect } from "@calcom/prisma"; import { baseEventTypeSelect, baseUserSelect } from "@calcom/prisma";
@ -528,6 +528,7 @@ export const eventTypesRouter = router({
periodType, periodType,
locations, locations,
bookingLimits, bookingLimits,
durationLimits,
destinationCalendar, destinationCalendar,
customInputs, customInputs,
recurringEvent, recurringEvent,
@ -582,12 +583,19 @@ export const eventTypesRouter = router({
} }
if (bookingLimits) { if (bookingLimits) {
const isValid = validateBookingLimitOrder(bookingLimits); const isValid = validateIntervalLimitOrder(bookingLimits);
if (!isValid) if (!isValid)
throw new TRPCError({ code: "BAD_REQUEST", message: "Booking limits must be in ascending order." }); throw new TRPCError({ code: "BAD_REQUEST", message: "Booking limits must be in ascending order." });
data.bookingLimits = bookingLimits; 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) { if (schedule) {
// Check that the schedule belongs to the user // Check that the schedule belongs to the user
const userScheduleQuery = await ctx.prisma.schedule.findFirst({ const userScheduleQuery = await ctx.prisma.schedule.findFirst({
@ -769,6 +777,7 @@ export const eventTypesRouter = router({
team, team,
recurringEvent, recurringEvent,
bookingLimits, bookingLimits,
durationLimits,
metadata, metadata,
workflows, workflows,
// eslint-disable-next-line @typescript-eslint/no-unused-vars // 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, users: users ? { connect: users.map((user) => ({ id: user.id })) } : undefined,
recurringEvent: recurringEvent || undefined, recurringEvent: recurringEvent || undefined,
bookingLimits: bookingLimits ?? undefined, bookingLimits: bookingLimits ?? undefined,
durationLimits: durationLimits ?? undefined,
metadata: metadata === null ? Prisma.DbNull : metadata, metadata: metadata === null ? Prisma.DbNull : metadata,
bookingFields: eventType.bookingFields === null ? Prisma.DbNull : eventType.bookingFields, bookingFields: eventType.bookingFields === null ? Prisma.DbNull : eventType.bookingFields,
}; };

View File

@ -125,6 +125,7 @@ async function getEventType(ctx: { prisma: typeof prisma }, input: z.infer<typeo
beforeEventBuffer: true, beforeEventBuffer: true,
afterEventBuffer: true, afterEventBuffer: true,
bookingLimits: true, bookingLimits: true,
durationLimits: true,
schedulingType: true, schedulingType: true,
periodType: true, periodType: true,
periodStartDate: true, periodStartDate: true,
@ -263,6 +264,7 @@ export async function getSchedule(input: z.infer<typeof getScheduleSchema>, ctx:
eventTypeId: input.eventTypeId, eventTypeId: input.eventTypeId,
afterEventBuffer: eventType.afterEventBuffer, afterEventBuffer: eventType.afterEventBuffer,
beforeEventBuffer: eventType.beforeEventBuffer, beforeEventBuffer: eventType.beforeEventBuffer,
duration: input.duration || 0,
}, },
{ user: currentUser, eventType, currentSeats } { user: currentUser, eventType, currentSeats }
); );

View File

@ -4,6 +4,7 @@ import type { calendar_v3 } from "googleapis";
import type { Time } from "ical.js"; import type { Time } from "ical.js";
import type { TFunction } from "next-i18next"; import type { TFunction } from "next-i18next";
import type { Calendar } from "@calcom/features/calendars/weeklyview";
import type { Frequency } from "@calcom/prisma/zod-utils"; import type { Frequency } from "@calcom/prisma/zod-utils";
import type { Ensure } from "./utils"; import type { Ensure } from "./utils";
@ -115,7 +116,7 @@ export interface RecurringEvent {
tzid?: string | undefined; tzid?: string | undefined;
} }
export interface BookingLimit { export interface IntervalLimit {
PER_DAY?: number | undefined; PER_DAY?: number | undefined;
PER_WEEK?: number | undefined; PER_WEEK?: number | undefined;
PER_MONTH?: number | undefined; PER_MONTH?: number | undefined;