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:
co-authored by
Jeroen Reumkens
Omar López
Peer Richelsen
Alex van Andel
Hariom Balhara
Leo Giovanetti
parent
e772b4203e
commit
5293db29d9
@@ -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>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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")}{" "}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }
|
||||
: {
|
||||
|
||||
@@ -80,6 +80,7 @@ const commons = {
|
||||
destinationCalendar: null,
|
||||
team: null,
|
||||
requiresConfirmation: false,
|
||||
bookingLimits: null,
|
||||
hidden: false,
|
||||
userId: 0,
|
||||
workflows: [],
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { default as classNames } from "./classNames";
|
||||
export { default as isPrismaObj, isPrismaObjOrUndefined } from "./isPrismaObj";
|
||||
export * from "./isRecurringEvent";
|
||||
export * from "./isBookingLimits";
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
@@ -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])
|
||||
|
||||
@@ -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,
|
||||
|
||||
Vendored
+2
-2
@@ -1,4 +1,4 @@
|
||||
export interface BufferedBusyTime {
|
||||
start: string;
|
||||
end: string;
|
||||
start: string | Date;
|
||||
end: string | Date;
|
||||
}
|
||||
|
||||
Vendored
+7
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user