Feat Booking Limits (#4759)

* Add db relevant stuff

* Basic UI there still buggy

* This UI is hard - some progress

* Fix awful state mangament

* Fix re-ordering

* Working UI logic!

* Partical working minMax function

* Fix min max

* bookingLImits api + tests

* Moved checkBookingLimits to backend only code

* Fix httperror import

* Return busy times

* Remove avaliablity calc

* Working for everything but year

* Remove redundant + fix async forloop

* Add compatible type

* Future proof with evenTypeId filter

* Fix commonjson

* Sorting + validation + tests + passing

* Add empty test

* Move validation check  to backend

* Add bookinglimits in trpc

* Add test for undefined

* Apply suggestions from code review

Co-authored-by: Jeroen Reumkens <hello@jeroenreumkens.nl>

* Update apps/web/components/v2/eventtype/EventLimitsTab.tsx

Co-authored-by: Jeroen Reumkens <hello@jeroenreumkens.nl>

* Rename value for eligiability

* Rename keyof type

* status code

* Fix toggle not toggling off

* Update apps/web/pages/v2/event-types/[type]/index.tsx

Co-authored-by: Omar López <zomars@me.com>

* Update apps/web/pages/v2/event-types/[type]/index.tsx

Co-authored-by: Omar López <zomars@me.com>

* Change back to undefined as it is working for sean. See if it fails on testapp

* Fixing test builder

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Jeroen Reumkens <hello@jeroenreumkens.nl>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: Leo Giovanetti <hello@leog.me>
This commit is contained in:
sean-brydon
2022-10-12 05:29:04 +00:00
committed by GitHub
co-authored by Jeroen Reumkens Omar López Peer Richelsen Alex van Andel Hariom Balhara Leo Giovanetti
parent e772b4203e
commit 5293db29d9
22 changed files with 551 additions and 20 deletions
@@ -1,19 +1,21 @@
import autoAnimate from "@formkit/auto-animate";
import * as RadioGroup from "@radix-ui/react-radio-group";
import { EventTypeSetupInfered, FormValues } from "pages/v2/event-types/[type]";
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useFormContext, Controller, useWatch } from "react-hook-form";
import { classNames } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { PeriodType } from "@calcom/prisma/client";
import { Select, Switch, Label, Input, MinutesField } from "@calcom/ui/v2";
import type { BookingLimit } from "@calcom/types/Calendar";
import { Icon } from "@calcom/ui";
import { Select, Switch, Label, Input, MinutesField, Button } from "@calcom/ui/v2";
import DateRangePicker from "@calcom/ui/v2/core/form/date-range-picker/DateRangePicker";
export const EventLimitsTab = (props: Pick<EventTypeSetupInfered, "eventType">) => {
const { t } = useLocale();
const formMethods = useFormContext<FormValues>();
const { eventType } = props;
const PERIOD_TYPES = [
{
type: "ROLLING" as const,
@@ -154,7 +156,49 @@ export const EventLimitsTab = (props: Pick<EventTypeSetupInfered, "eventType">)
/>
</div>
</div>
<hr className="my-8" />
<div className="flex flex-col space-y-4 lg:flex-row lg:space-y-0 lg:space-x-4">
<fieldset className="block flex-col sm:flex">
<div className="flex space-x-3">
<Controller
name="bookingLimits"
control={formMethods.control}
render={({ field: { value } }) => (
<Switch
fitToHeight={true}
checked={Object.keys(value ?? {}).length > 0}
onCheckedChange={(active) => {
if (active) {
formMethods.setValue("bookingLimits", {
PER_DAY: 1,
});
} else {
formMethods.setValue("bookingLimits", undefined);
}
}}
/>
)}
/>
<div className="">
<Label className="text-sm font-semibold leading-none text-black">
{t("limit_booking_frequency")}
</Label>
<p className="-mt-2 text-sm leading-normal text-gray-600">
{t("limit_booking_frequency_description")}
</p>
</div>
</div>
<div className="mt-4 lg:ml-14">
<BookingLimits />
</div>
</fieldset>
</div>
<hr className="my-8" />
<div className="flex flex-col space-y-4 lg:flex-row lg:space-y-0 lg:space-x-4">
<fieldset className="block flex-col sm:flex">
<div className="flex space-x-3">
@@ -261,3 +305,130 @@ export const EventLimitsTab = (props: Pick<EventTypeSetupInfered, "eventType">)
</div>
);
};
const validationOrderKeys = ["PER_DAY", "PER_WEEK", "PER_MONTH", "PER_YEAR"];
type BookingLimitsKey = keyof BookingLimit;
const BookingLimits = () => {
const { watch, setValue, control } = useFormContext<FormValues>();
const watchBookingLimits = watch("bookingLimits");
const animateRef = useRef(null);
const { t } = useLocale();
useEffect(() => {
animateRef.current && autoAnimate(animateRef.current);
}, [animateRef]);
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"
control={control}
render={({ field: { value, onChange } }) => {
const currentBookingLimits = value;
return (
<ul ref={animateRef}>
{currentBookingLimits &&
watchBookingLimits &&
Object.entries(currentBookingLimits)
.sort(([limitkeyA], [limitKeyB]) => {
return (
validationOrderKeys.indexOf(limitkeyA as BookingLimitsKey) -
validationOrderKeys.indexOf(limitKeyB as BookingLimitsKey)
);
})
.map(([key, bookingAmount]) => {
const bookingLimitKey = key as BookingLimitsKey;
return (
<div className="mb-2 flex items-center space-x-2 text-sm" 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;
// 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]: watchBookingLimits[bookingLimitKey],
};
onChange(newData);
}}
/>
<Button
size="icon"
StartIcon={Icon.FiTrash}
color="destructive"
onClick={() => {
const current = currentBookingLimits;
delete current[key as BookingLimitsKey];
onChange(current);
}}
/>
</div>
);
})}
{currentBookingLimits && Object.keys(currentBookingLimits).length <= 3 && (
<Button
color="minimal"
StartIcon={Icon.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]: undefined,
});
}}>
{t("add_limit")}
</Button>
)}
</ul>
);
}}
/>
);
};
+9 -2
View File
@@ -22,12 +22,12 @@ import {
} from "@calcom/emails";
import { scheduleWorkflowReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler";
import getWebhooks from "@calcom/features/webhooks/utils/getWebhooks";
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
import { isPrismaObjOrUndefined, parseBookingLimit, parseRecurringEvent } from "@calcom/lib";
import { getDefaultEvent, getGroupName, getUsernameList } from "@calcom/lib/defaultEvents";
import { getErrorFromUnknown } from "@calcom/lib/errors";
import isOutOfBounds, { BookingDateInPastError } from "@calcom/lib/isOutOfBounds";
import logger from "@calcom/lib/logger";
import { defaultResponder, getLuckyUser } from "@calcom/lib/server";
import { defaultResponder, getLuckyUser, checkBookingLimits } from "@calcom/lib/server";
import { updateWebUser as syncServicesUpdateWebUser } from "@calcom/lib/sync/SyncServiceManager";
import prisma, { userSelect } from "@calcom/prisma";
import { extendedBookingCreateBody, requiredCustomInputSchema } from "@calcom/prisma/zod-utils";
@@ -144,6 +144,7 @@ const getEventTypesFromDB = async (eventTypeId: number) => {
destinationCalendar: true,
hideCalendarNotes: true,
seatsPerTimeSlot: true,
bookingLimits: true,
recurringEvent: true,
workflows: {
include: {
@@ -304,6 +305,7 @@ async function handler(req: NextApiRequest) {
...userSelect,
})
: eventType.users;
const isDynamicAllowed = !users.some((user) => !user.allowDynamicBooking);
if (!isDynamicAllowed && !eventTypeId) {
throw new HttpError({
@@ -327,6 +329,11 @@ async function handler(req: NextApiRequest) {
if (!users) throw new HttpError({ statusCode: 404, message: "eventTypeUser.notFound" });
if (eventType && eventType.hasOwnProperty("bookingLimits") && eventType?.bookingLimits) {
const startAsDate = dayjs(reqBody.start).toDate();
await checkBookingLimits(eventType.bookingLimits, startAsDate, eventType.id);
}
if (!eventType.seatsPerTimeSlot) {
const availableUsers = await ensureAvailableUsers(
{
+2 -2
View File
@@ -69,11 +69,11 @@ const AvailabilityView = ({ user }: { user: User }) => {
className="overflow-hidden rounded-sm bg-neutral-100">
<div className="px-4 py-5 text-black sm:p-6">
{t("calendar_shows_busy_between")}{" "}
<span className="font-medium text-neutral-800" title={slot.start}>
<span className="font-medium text-neutral-800" title={dayjs(slot.start).format("HH:mm")}>
{dayjs(slot.start).format("HH:mm")}
</span>{" "}
{t("and")}{" "}
<span className="font-medium text-neutral-800" title={slot.end}>
<span className="font-medium text-neutral-800" title={dayjs(slot.end).format("HH:mm")}>
{dayjs(slot.end).format("HH:mm")}
</span>{" "}
{t("on")} {dayjs(slot.start).format("D")}{" "}
@@ -9,8 +9,8 @@ import { SkeletonText } from "@calcom/ui/v2/core/skeleton";
type User = inferQueryOutput<"viewer.me">;
export interface IBusySlot {
start: string;
end: string;
start: string | Date;
end: string | Date;
title?: string;
source?: string | null;
}
@@ -62,14 +62,16 @@ const AvailabilityView = ({ user }: { user: User }) => {
data.busy
.sort((a: IBusySlot, b: IBusySlot) => (a.start > b.start ? -1 : 1))
.map((slot: IBusySlot) => (
<div key={slot.start} className="overflow-hidden rounded-md bg-neutral-100">
<div
key={dayjs(slot.start).format("HH:mm")}
className="overflow-hidden rounded-md bg-neutral-100">
<div className="px-4 py-5 text-black sm:p-6">
{t("calendar_shows_busy_between")}{" "}
<span className="font-medium text-neutral-800" title={slot.start}>
<span className="font-medium text-neutral-800" title={dayjs(slot.start).format("HH:mm")}>
{dayjs(slot.start).format("HH:mm")}
</span>{" "}
{t("and")}{" "}
<span className="font-medium text-neutral-800" title={slot.end}>
<span className="font-medium text-neutral-800" title={dayjs(slot.end).format("HH:mm")}>
{dayjs(slot.end).format("HH:mm")}
</span>{" "}
{t("on")} {dayjs(slot.start).format("D")}{" "}
+13 -2
View File
@@ -11,12 +11,12 @@ import { z } from "zod";
import { StripeData } from "@calcom/app-store/stripepayment/lib/server";
import getApps, { getLocationOptions } from "@calcom/app-store/utils";
import { LocationObject, EventLocationType } from "@calcom/core/location";
import { parseRecurringEvent } from "@calcom/lib";
import { parseRecurringEvent, parseBookingLimit, validateBookingLimitOrder } from "@calcom/lib";
import { CAL_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import prisma from "@calcom/prisma";
import { trpc } from "@calcom/trpc/react";
import type { RecurringEvent } from "@calcom/types/Calendar";
import type { BookingLimit, RecurringEvent } from "@calcom/types/Calendar";
import { Form } from "@calcom/ui/form/fields";
import { showToast } from "@calcom/ui/v2";
@@ -83,6 +83,7 @@ export type FormValues = {
giphyThankYouPage: string;
blockchainId: number;
smartContractAddress: string;
bookingLimits?: BookingLimit;
};
const querySchema = z.object({
@@ -150,6 +151,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
recurringEvent: eventType.recurringEvent || null,
description: eventType.description ?? undefined,
schedule: eventType.schedule || undefined,
bookingLimits: eventType.bookingLimits || undefined,
hidden: eventType.hidden,
periodDates: {
startDate: periodDates.startDate,
@@ -220,6 +222,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
beforeBufferTime,
afterBufferTime,
seatsPerTimeSlot,
bookingLimits,
recurringEvent,
locations,
blockchainId,
@@ -230,6 +233,11 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
...input
} = values;
if (bookingLimits) {
const isValid = validateBookingLimitOrder(bookingLimits);
if (!isValid) throw new Error("Booking limits must be in accending order. [day,week,month,year]");
}
updateMutation.mutate({
...input,
locations,
@@ -240,6 +248,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
id: eventType.id,
beforeEventBuffer: beforeBufferTime,
afterEventBuffer: afterBufferTime,
bookingLimits,
seatsPerTimeSlot,
metadata: {
...(giphyThankYouPage ? { giphyThankYouPage } : {}),
@@ -344,6 +353,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
afterEventBuffer: true,
slotInterval: true,
hashedLink: true,
bookingLimits: true,
successRedirectUrl: true,
team: {
select: {
@@ -422,6 +432,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
...restEventType,
schedule: rawEventType.schedule?.id || rawEventType.users[0].defaultScheduleId,
recurringEvent: parseRecurringEvent(restEventType.recurringEvent),
bookingLimits: parseBookingLimit(restEventType.bookingLimits),
locations: locations as unknown as LocationObject[],
metadata: (metadata || {}) as JSONObject,
};
@@ -1268,6 +1268,9 @@
"seats": "seats",
"every_app_published": "Every app published on the Cal.com App Store is open source and thoroughly tested via peer reviews. Nevertheless, Cal.com, Inc. does not endorse or certify these apps unless they are published by Cal.com. If you encounter inappropriate content or behaviour please report it.",
"report_app": "Report app",
"limit_booking_frequency":"Limit booking frequency",
"limit_booking_frequency_description":"Limit how many times this event can be booked",
"add_limit":"Add Limit",
"team_name_required": "Team name required",
"how_additional_inputs_as_variables": "How to use Additional Inputs as Variables",
"format": "Format",
@@ -0,0 +1,119 @@
import dayjs from "@calcom/dayjs";
import { validateBookingLimitOrder } from "@calcom/lib/isBookingLimits";
import { checkBookingLimits, checkLimit } from "@calcom/lib/server";
import { BookingLimit } from "@calcom/types/Calendar";
import { prismaMock } from "../../../../tests/config/singleton";
type Mockdata = {
id: number;
startDate: Date;
bookingLimits: BookingLimit;
};
const MOCK_DATA: Mockdata = {
id: 1,
startDate: dayjs("2022-09-30T09:00:00+01:00").toDate(),
bookingLimits: {
PER_DAY: 1,
},
};
describe("Check Booking Limits Tests", () => {
it("Should return no errors", async () => {
prismaMock.booking.count.mockResolvedValue(0);
expect(
checkBookingLimits(MOCK_DATA.bookingLimits, MOCK_DATA.startDate, MOCK_DATA.id)
).resolves.toBeTruthy();
});
it("Should throw an error", async () => {
// Mock there being two a day
prismaMock.booking.count.mockResolvedValue(2);
expect(
checkBookingLimits(MOCK_DATA.bookingLimits, MOCK_DATA.startDate, MOCK_DATA.id)
).rejects.toThrowError();
});
it("Should pass with multiple booking limits", async () => {
prismaMock.booking.count.mockResolvedValue(0);
expect(
checkBookingLimits(
{
PER_DAY: 1,
PER_WEEK: 2,
},
MOCK_DATA.startDate,
MOCK_DATA.id
)
).resolves.toBeTruthy();
});
it("Should pass with multiple booking limits with one undefined", async () => {
prismaMock.booking.count.mockResolvedValue(0);
expect(
checkBookingLimits(
{
PER_DAY: 1,
PER_WEEK: undefined,
},
MOCK_DATA.startDate,
MOCK_DATA.id
)
).resolves.toBeTruthy();
});
it("Should handle mutiple limits correctly", async () => {
prismaMock.booking.count.mockResolvedValue(1);
expect(
checkLimit({
key: "PER_DAY",
limitingNumber: 2,
eventStartDate: MOCK_DATA.startDate,
eventId: MOCK_DATA.id,
})
).resolves.not.toThrow();
prismaMock.booking.count.mockResolvedValue(3);
expect(
checkLimit({
key: "PER_WEEK",
limitingNumber: 2,
eventStartDate: MOCK_DATA.startDate,
eventId: MOCK_DATA.id,
})
).rejects.toThrowError();
});
it("Should return busyTimes when set", async () => {
prismaMock.booking.count.mockResolvedValue(2);
expect(
checkLimit({
key: "PER_DAY",
limitingNumber: 2,
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("Booking limit validation", () => {
it("Should validate a correct limit", () => {
expect(validateBookingLimitOrder({ PER_DAY: 3, PER_MONTH: 5 })).toBe(true);
});
it("Should invalidate an incorrect limit", () => {
expect(validateBookingLimitOrder({ 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);
});
it("Should validate a correct limit with equal values ", () => {
expect(validateBookingLimitOrder({ PER_DAY: 1, PER_YEAR: 1 })).toBe(true);
});
it("Should validate a correct with empty", () => {
expect(validateBookingLimitOrder({})).toBe(true);
});
});
+1 -1
View File
@@ -55,7 +55,7 @@ export async function getBusyTimes(params: {
if (credentials?.length > 0) {
const calendarBusyTimes = await getBusyCalendarTimes(credentials, startTime, endTime, selectedCalendars);
busyTimes.push(...calendarBusyTimes); /*
busyTimes.push(...calendarBusyTimes); /*
// TODO: Disabled until we can filter Zoom events by date. Also this is adding too much latency.
const videoBusyTimes = (await getBusyVideoTimes(credentials)).filter(notEmpty);
console.log("videoBusyTimes", videoBusyTimes);
+61 -2
View File
@@ -2,12 +2,15 @@ import { Prisma } from "@prisma/client";
import { z } from "zod";
import dayjs, { Dayjs } from "@calcom/dayjs";
import { parseBookingLimit } 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 { performance } from "@calcom/lib/server/perfObserver";
import prisma, { availabilityUserSelect } from "@calcom/prisma";
import { stringToDayjs } from "@calcom/prisma/zod-utils";
import { BookingLimit, EventBusyDetails } from "@calcom/types/Calendar";
import { getBusyTimes } from "./getBusyTimes";
@@ -29,6 +32,7 @@ const getEventType = (id: number) =>
select: {
id: true,
seatsPerTimeSlot: true,
bookingLimits: true,
timeZone: true,
schedule: {
select: {
@@ -115,8 +119,11 @@ export async function getUserAvailability(
/* Current logic is if a booking is in a time slot mark it as busy, but seats can have more than one attendee so grab
current bookings with a seats event type and display them on the calendar, even if they are full */
let currentSeats: CurrentSeats | null = initialData?.currentSeats || null;
if (!currentSeats && eventType?.seatsPerTimeSlot)
if (!currentSeats && eventType?.seatsPerTimeSlot) {
currentSeats = await getCurrentSeats(eventType.id, dateFrom, dateTo);
}
const bookingLimits = parseBookingLimit(eventType?.bookingLimits);
const { selectedCalendars, ...currentUser } = user;
@@ -129,7 +136,7 @@ export async function getUserAvailability(
selectedCalendars,
});
const bufferedBusyTimes = busyTimes.map((a) => ({
const bufferedBusyTimes: EventBusyDetails[] = busyTimes.map((a) => ({
...a,
start: dayjs(a.start).subtract(currentUser.bufferTime, "minute").toISOString(),
end: dayjs(a.end)
@@ -139,6 +146,58 @@ export async function getUserAvailability(
source: query.withSource ? a.source : undefined,
}));
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.add(1, "day"));
startDate = startDate.add(1, "day");
}
const ourBookings = busyTimes.filter((busyTime) =>
busyTime.source?.startsWith(`eventType-${eventType?.id}`)
);
// 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].toLocaleLowerCase() as "day" | "week" | "month" | "year";
let total = 0;
// Get all bookings that are within the filter period
ourBookings.forEach((booking) => {
const startDate = dayjs(booking.start).startOf(filter);
// this is parsed above with parseBookingLimit so we know it's safe.
const endDate = dayjs(startDate).endOf(filter);
if (dayjs(booking.start).isBetween(startDate, endDate)) total++;
if (total >= limit)
bufferedBusyTimes.push({ start: startDate.toISOString(), end: endDate.toISOString() });
});
}
}
const schedule = eventType?.schedule
? { ...eventType?.schedule }
: {
+1
View File
@@ -80,6 +80,7 @@ const commons = {
destinationCalendar: null,
team: null,
requiresConfirmation: false,
bookingLimits: null,
hidden: false,
userId: 0,
workflows: [],
+1
View File
@@ -1,3 +1,4 @@
export { default as classNames } from "./classNames";
export { default as isPrismaObj, isPrismaObjOrUndefined } from "./isPrismaObj";
export * from "./isRecurringEvent";
export * from "./isBookingLimits";
+29
View File
@@ -0,0 +1,29 @@
import { bookingLimitsType } from "@calcom/prisma/zod-utils";
import { BookingLimit } from "@calcom/types/Calendar";
export function isBookingLimit(obj: unknown): obj is BookingLimit {
return bookingLimitsType.safeParse(obj).success;
}
export function parseBookingLimit(obj: unknown): BookingLimit | null {
let bookingLimit: BookingLimit | 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;
};
+94
View File
@@ -0,0 +1,94 @@
import dayjs from "@calcom/dayjs";
import prisma from "@calcom/prisma";
import { BookingLimit } from "@calcom/types/Calendar";
import { HttpError } from "../http-error";
import { parseBookingLimit } from "../isBookingLimits";
export async function checkBookingLimits(
bookingLimits: any,
eventStartDate: Date,
eventId: number,
returnBusyTimes?: boolean
) {
const parsedBookingLimits = parseBookingLimit(bookingLimits);
if (parsedBookingLimits) {
const limitCalculations = Object.entries(parsedBookingLimits).map(
async ([key, limitingNumber]) =>
await checkLimit({ key, limitingNumber, eventStartDate, eventId, returnBusyTimes })
);
await Promise.all(limitCalculations)
.then((res) => {
if (returnBusyTimes) {
return res;
}
})
.catch((error) => {
throw new HttpError({ message: error.message, statusCode: 401 });
});
return true;
}
return false;
}
export async function checkLimit({
eventStartDate,
eventId,
key,
limitingNumber,
returnBusyTimes = false,
}: {
eventStartDate: Date;
eventId: number;
key: string;
limitingNumber: number;
returnBusyTimes?: boolean;
}) {
{
const limitKey = key as keyof BookingLimit;
// 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();
// this is parsed above with parseBookingLimit so we know it's safe.
const endDate = dayjs(startDate).endOf(filter).toDate();
// This allows us to easily add it within dayjs
const bookingsInPeriod = await prisma.booking.count({
where: {
status: "ACCEPTED",
eventType: {
AND: [
{
id: eventId,
bookings: {
some: {
startTime: {
gte: startDate,
},
endTime: {
lte: endDate,
},
},
},
},
],
},
},
});
if (bookingsInPeriod >= limitingNumber) {
// This is used when getting availbility
if (returnBusyTimes) {
return {
start: startDate,
end: endDate,
};
}
throw new HttpError({
message: `Booking limit of ${limitingNumber}/${filter} reached for this eventType`,
statusCode: 403,
});
}
}
}
+2
View File
@@ -1,3 +1,5 @@
export { checkBookingLimits, checkLimit } from "./checkBookingLimits";
export { default as defaultHandler } from "./defaultHandler";
export { default as defaultResponder } from "./defaultResponder";
export { getLuckyUser } from "./getLuckyUser";
+1
View File
@@ -87,6 +87,7 @@ export const buildEventType = (eventType?: Partial<EventType>): EventType => {
seatsPerTimeSlot: null,
schedulingType: null,
scheduleId: null,
bookingLimits: null,
price: 0,
currency: "usd",
slotInterval: null,
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "EventType" ADD COLUMN "bookingLimits" JSONB;
+2
View File
@@ -78,6 +78,8 @@ model EventType {
/// @zod.custom(imports.successRedirectUrl)
successRedirectUrl String?
workflows WorkflowsOnEventTypes[]
/// @zod.custom(imports.bookingLimitsType)
bookingLimits Json?
@@unique([userId, slug])
@@unique([teamId, slug])
+9
View File
@@ -48,6 +48,15 @@ export const recurringEventType = z
})
.nullable();
export const bookingLimitsType = z
.object({
PER_DAY: z.number().optional(),
PER_WEEK: z.number().optional(),
PER_MONTH: z.number().optional(),
PER_YEAR: z.number().optional(),
})
.nullable();
export const eventTypeSlug = z.string().transform((val) => slugify(val.trim()));
export const stringToDate = z.string().transform((a) => new Date(a));
@@ -5,6 +5,7 @@ import { z } from "zod";
import getAppKeysFromSlug from "@calcom/app-store/_utils/getAppKeysFromSlug";
import { DailyLocationType } from "@calcom/app-store/locations";
import { stripeDataSchema } from "@calcom/app-store/stripepayment/lib/server";
import { validateBookingLimitOrder } from "@calcom/lib";
import { _DestinationCalendarModel, _EventTypeCustomInputModel, _EventTypeModel } from "@calcom/prisma/zod";
import { stringOrNumber } from "@calcom/prisma/zod-utils";
import { createEventTypeInput } from "@calcom/prisma/zod/custom/eventtype";
@@ -257,6 +258,7 @@ export const eventTypesRouter = createProtectedRouter()
schedule,
periodType,
locations,
bookingLimits,
destinationCalendar,
customInputs,
recurringEvent,
@@ -296,6 +298,13 @@ export const eventTypesRouter = createProtectedRouter()
data.customInputs = handleCustomInputs(customInputs, id);
}
if (bookingLimits) {
const isValid = validateBookingLimitOrder(bookingLimits);
if (!isValid)
throw new TRPCError({ code: "BAD_REQUEST", message: "Booking limits must be in ascending order." });
data.bookingLimits = bookingLimits;
}
if (schedule) {
data.schedule = {
connect: {
@@ -11,6 +11,7 @@ import logger from "@calcom/lib/logger";
import { performance } from "@calcom/lib/server/perfObserver";
import getTimeSlots from "@calcom/lib/slots";
import prisma, { availabilityUserSelect } from "@calcom/prisma";
import { EventBusyDate } from "@calcom/types/Calendar";
import { TimeRange } from "@calcom/types/schedule";
import { TRPCError } from "@trpc/server";
@@ -53,7 +54,7 @@ const checkIfIsAvailable = ({
currentSeats,
}: {
time: Dayjs;
busy: (TimeRange | { start: string; end: string })[];
busy: (TimeRange | { start: string; end: string } | EventBusyDate)[];
eventLength: number;
beforeBufferTime: number;
currentSeats?: CurrentSeats;
@@ -118,6 +119,7 @@ async function getEventType(ctx: { prisma: typeof prisma }, input: z.infer<typeo
slotInterval: true,
beforeEventBuffer: true,
afterEventBuffer: true,
bookingLimits: true,
schedulingType: true,
periodType: true,
periodStartDate: true,
+2 -2
View File
@@ -1,4 +1,4 @@
export interface BufferedBusyTime {
start: string;
end: string;
start: string | Date;
end: string | Date;
}
+7
View File
@@ -105,6 +105,13 @@ export interface RecurringEvent {
tzid?: string | undefined;
}
export interface BookingLimit {
PER_DAY?: number | undefined;
PER_WEEK?: number | undefined;
PER_MONTH?: number | undefined;
PER_YEAR?: number | undefined;
}
// If modifying this interface, probably should update builders/calendarEvent files
export interface CalendarEvent {
type: string;