[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.
## Priorities
## Priorities
<table>
<tr>
@ -57,7 +57,6 @@ Contributions are what make the open source community such an amazing place to l
</tr>
</table>
## Developing
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">
</picture>
</a>
<!-- CONTRIBUTORS -->
### Contributors
<a href="https://github.com/calcom/cal.com/graphs/contributors">
<img src="https://contrib.rocks/image?repo=calcom/cal.com" />
</a>
<!-- TRANSLATIONS -->
### Translations

View File

@ -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<EventTypeSetupProps, "eventTy
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>
)}
/>
@ -348,124 +386,158 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
);
};
const validationOrderKeys = ["PER_DAY", "PER_WEEK", "PER_MONTH", "PER_YEAR"];
type BookingLimitsKey = keyof BookingLimit;
const BookingLimits = () => {
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 (
<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 watchBookingLimits = watch("bookingLimits");
const watchIntervalLimits = watch(propertyName);
const { t } = useLocale();
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 (
<Controller
name="bookingLimits"
name={propertyName}
control={control}
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 (
<ul ref={animateRef}>
{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 (
<div
className="mb-2 flex items-center space-x-2 text-sm rtl:space-x-reverse"
key={bookingLimitKey}>
<Input
id={`${bookingLimitKey}-limit`}
type="number"
className="mb-0 block w-16 rounded-md border-gray-300 text-sm [appearance:textfield]"
placeholder="1"
min={1}
defaultValue={bookingAmount}
onChange={(e) => {
const val = e.target.value;
setValue(`bookingLimits.${bookingLimitKey}`, parseInt(val));
}}
/>
<Select
options={BOOKING_LIMIT_OPTIONS.filter(
(option) => !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];
<IntervalLimitItem
key={key}
limitKey={limitKey}
step={step}
value={value}
textFieldSuffix={textFieldSuffix}
hasDeleteButton={Object.keys(currentIntervalLimits).length > 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);
}}
/>
<Button
variant="icon"
StartIcon={FiTrash}
color="destructive"
onClick={() => {
const current = currentBookingLimits;
delete current[key as BookingLimitsKey];
onChange(current);
}}
/>
</div>
// 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 && (
<Button
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,
});
}}>
{currentIntervalLimits && Object.keys(currentIntervalLimits).length <= 3 && (
<Button color="minimal" StartIcon={FiPlus} onClick={addLimit}>
{t("add_limit")}
</Button>
)}

View File

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

View File

@ -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.",

View File

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

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

View File

@ -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(
{

View File

@ -83,6 +83,7 @@ const commons = {
team: null,
requiresConfirmation: false,
bookingLimits: null,
durationLimits: null,
hidden: false,
userId: 0,
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 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,

View File

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

View File

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

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 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,

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 { 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 "./booking";

View File

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

View File

@ -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(),

View File

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

View File

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

View File

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