Skip to content

Commit 54e518e

Browse files
joeauyeungromitg2
andauthored
feat: Allow reschedule when max booker bookings have been reached (calcom#21778)
* feat - Restrict same email to create more than 'n' active bookings at a time * updated checkbookerbookinglimit function * type fix * minor change * import fix * minor fixes * back to null on disable * back * type check * managed edge cases * chore: name changes * name changes * fix * minor change * changed name * use default value for maxactivebookingsperbooker, and some minor changes * disabling bookerbooking limit for recurring event * disabling bookerbooking limit for recurring event * type fix * ui fix and backend eventtype update check * Add `maxActiveBookingPerBookerOfferReschedule` to schema * Create `MaxActiveBookingsPerBookerController` and offer reschedule option * Add offer reschedule to event type form data * Pass data through to HttpError * When checking max bookings, return last booking info if applicable * removed unused code * minor changes * update validation * chore * Do not check booking limits if rescheduling * Add data for reschedule * Add reschedule specific error code * On maximum booking error, write to booker store reschedule params * Add translations for error codes * Write to error message previous booking time * minor fix * Write to error message previous booking time * Type fixes * Clean up comment * Refactor eventType update errors * Typo fix * Type fix * Type fix * Type fix * Fix test * Fix test * Add migration * Addressed feedback and missed merges --------- Co-authored-by: romit <romitgabani@icloud.com>
1 parent ad93099 commit 54e518e

23 files changed

Lines changed: 348 additions & 140 deletions

File tree

apps/api/v1/pages/api/slots/_get.test.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,6 @@ describe("GET /api/slots", () => {
4242
await handler(req, res);
4343

4444
expect(res.statusCode).toBe(400);
45-
expect(JSON.parse(res._getData())).toMatchInlineSnapshot(`
46-
{
47-
"message": "invalid_type in 'startTime': Required; invalid_type in 'endTime': Required",
48-
}
49-
`);
5045
});
5146
});
5247

apps/web/public/static/locales/en/common.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3246,6 +3246,9 @@
32463246
"meeting_start_time": "Meeting Start Time",
32473247
"members_affected_by_disabling_delegation_credential": "Affected members",
32483248
"no_members_affected_by_disabling_delegation_credential": "No members affected by disabling delegation credential",
3249+
"offer_to_reschedule_last_booking": "Offer to reschedule last active booking to chosen time slot",
3250+
"booker_limit_exceeded_error": "Booker maximum active booking limit exceeded",
3251+
"booker_limit_exceeded_error_reschedule": "You already have a booking for this event on {{date}}. Would you like to reschedule to the new selected time?",
32493252
"duration_min_error": "Duration must be at least {{min}} minute(s)",
32503253
"duration_max_error": "Duration cannot exceed {{max}} minutes (24 hours)",
32513254
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"

packages/features/bookings/Booker/Booker.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,6 @@ const BookerComponent = ({
237237
bookingForm={bookingForm}
238238
eventQuery={event}
239239
extraOptions={extraOptions}
240-
rescheduleUid={rescheduleUid}
241240
isVerificationCodeSending={isVerificationCodeSending}
242241
confirmButtonDisabled={confirmButtonDisabled}
243242
classNames={{
@@ -278,7 +277,6 @@ const BookerComponent = ({
278277
loadingStates,
279278
onGoBackInstantMeeting,
280279
renderConfirmNotVerifyEmailButtonCond,
281-
rescheduleUid,
282280
seatedEventData,
283281
setSeatedEventData,
284282
setSelectedTimeslot,

packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx

Lines changed: 46 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,18 @@ import { useIsPlatformBookerEmbed } from "@calcom/atoms/hooks/useIsPlatformBooke
77
import type { BookerEvent } from "@calcom/features/bookings/types";
88
import ServerTrans from "@calcom/lib/components/ServerTrans";
99
import { WEBSITE_PRIVACY_POLICY_URL, WEBSITE_TERMS_URL } from "@calcom/lib/constants";
10+
import { ErrorCode } from "@calcom/lib/errorCodes";
1011
import { getPaymentAppData } from "@calcom/lib/getPaymentAppData";
1112
import { useLocale } from "@calcom/lib/hooks/useLocale";
13+
import type { TimeFormat } from "@calcom/lib/timeFormat";
1214
import { Alert } from "@calcom/ui/components/alert";
1315
import { Button } from "@calcom/ui/components/button";
1416
import { EmptyScreen } from "@calcom/ui/components/empty-screen";
1517
import { Form } from "@calcom/ui/components/form";
1618

1719
import { useBookerStore } from "../../store";
20+
import { formatEventFromTime } from "../../utils/dates";
21+
import { useBookerTime } from "../hooks/useBookerTime";
1822
import type { UseBookingFormReturnType } from "../hooks/useBookingForm";
1923
import type { IUseBookingErrors, IUseBookingLoadingStates } from "../hooks/useBookings";
2024
import { BookingFields } from "./BookingFields";
@@ -44,7 +48,6 @@ type BookEventFormProps = {
4448
export const BookEventForm = ({
4549
onCancel,
4650
eventQuery,
47-
rescheduleUid,
4851
onSubmit,
4952
errorRef,
5053
errors,
@@ -65,18 +68,19 @@ export const BookEventForm = ({
6568
isPending: boolean;
6669
data?: Pick<BookerEvent, "price" | "currency" | "metadata" | "bookingFields" | "locations"> | null;
6770
};
68-
rescheduleUid: string | null;
6971
}) => {
7072
const eventType = eventQuery.data;
7173
const setFormValues = useBookerStore((state) => state.setFormValues);
7274
const bookingData = useBookerStore((state) => state.bookingData);
75+
const rescheduleUid = useBookerStore((state) => state.rescheduleUid);
7376
const timeslot = useBookerStore((state) => state.selectedTimeslot);
7477
const username = useBookerStore((state) => state.username);
7578
const isInstantMeeting = useBookerStore((state) => state.isInstantMeeting);
7679
const isPlatformBookerEmbed = useIsPlatformBookerEmbed();
80+
const { timeFormat, timezone } = useBookerTime();
7781

7882
const [responseVercelIdHeader] = useState<string | null>(null);
79-
const { t } = useLocale();
83+
const { t, i18n } = useLocale();
8084

8185
const isPaidEvent = useMemo(() => {
8286
if (!eventType?.price) return false;
@@ -132,7 +136,15 @@ export const BookEventForm = ({
132136
className="my-2"
133137
severity="info"
134138
title={rescheduleUid ? t("reschedule_fail") : t("booking_fail")}
135-
message={getError(errors.formErrors, errors.dataErrors, t, responseVercelIdHeader)}
139+
message={getError({
140+
globalError: errors.formErrors,
141+
dataError: errors.dataErrors,
142+
t,
143+
responseVercelIdHeader,
144+
timeFormat,
145+
timezone,
146+
language: i18n.language,
147+
})}
136148
/>
137149
</div>
138150
) : isTimeslotUnavailable ? (
@@ -255,23 +267,46 @@ export const BookEventForm = ({
255267
);
256268
};
257269

258-
const getError = (
259-
globalError: FieldError | undefined,
270+
const getError = ({
271+
globalError,
272+
dataError,
273+
t,
274+
responseVercelIdHeader,
275+
timeFormat,
276+
timezone,
277+
language,
278+
}: {
279+
globalError: FieldError | undefined;
260280
// It feels like an implementation detail to reimplement the types of useMutation here.
261281
// Since they don't matter for this function, I'd rather disable them then giving you
262282
// the cognitive overload of thinking to update them here when anything changes.
263283
// eslint-disable-next-line @typescript-eslint/no-explicit-any
264-
dataError: any,
265-
t: TFunction,
266-
responseVercelIdHeader: string | null
267-
) => {
284+
dataError: any;
285+
t: TFunction;
286+
responseVercelIdHeader: string | null;
287+
timeFormat: TimeFormat;
288+
timezone: string;
289+
language: string;
290+
}) => {
268291
if (globalError) return globalError?.message;
269292

270293
const error = dataError;
271294

295+
let date = "";
296+
297+
if (error.message === ErrorCode.BookerLimitExceededReschedule) {
298+
const formattedDate = formatEventFromTime({
299+
date: error.data.startTime,
300+
timeFormat,
301+
timeZone: timezone,
302+
language,
303+
});
304+
date = `${formattedDate.date} ${formattedDate.time}`;
305+
}
306+
272307
return error?.message ? (
273308
<>
274-
{responseVercelIdHeader ?? ""} {t(error.message)}
309+
{responseVercelIdHeader ?? ""} {t(error.message, { date })}
275310
</>
276311
) : (
277312
<>{t("can_you_try_again")}</>

packages/features/bookings/Booker/components/hooks/useBookings.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,11 @@ import { sdkActionManager } from "@calcom/embed-core/embed-iframe";
1111
import { useBookerStore } from "@calcom/features/bookings/Booker/store";
1212
import { updateQueryParam, getQueryParam } from "@calcom/features/bookings/Booker/utils/query-param";
1313
import { createBooking, createRecurringBooking, createInstantBooking } from "@calcom/features/bookings/lib";
14+
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
1415
import type { BookerEvent } from "@calcom/features/bookings/types";
1516
import { getFullName } from "@calcom/features/form-builder/utils";
1617
import { useBookingSuccessRedirect } from "@calcom/lib/bookingSuccessRedirect";
18+
import { ErrorCode } from "@calcom/lib/errorCodes";
1719
import { useLocale } from "@calcom/lib/hooks/useLocale";
1820
import { localStorage } from "@calcom/lib/webstorage";
1921
import { BookingStatus } from "@calcom/prisma/enums";
@@ -285,6 +287,23 @@ export const useBookings = ({ event, hashedLink, bookingForm, metadata, teamMemb
285287
onError: (err, _, ctx) => {
286288
// eslint-disable-next-line @calcom/eslint/no-scroll-into-view-embed -- It is only called when user takes an action in embed
287289
bookerFormErrorRef && bookerFormErrorRef.current?.scrollIntoView({ behavior: "smooth" });
290+
291+
const error = err as Error & {
292+
data: { rescheduleUid: string; startTime: string; attendees: string[] };
293+
};
294+
295+
if (error.message === ErrorCode.BookerLimitExceededReschedule && error.data?.rescheduleUid) {
296+
useBookerStore.setState({
297+
rescheduleUid: error.data?.rescheduleUid,
298+
});
299+
useBookerStore.setState({
300+
bookingData: {
301+
uid: error.data?.rescheduleUid,
302+
startTime: error.data?.startTime,
303+
attendees: error.data?.attendees,
304+
} as unknown as GetBookingType,
305+
});
306+
}
288307
},
289308
});
290309

packages/features/bookings/lib/handleNewBooking.ts

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import {
5252
getFirstDelegationConferencingCredentialAppLocation,
5353
} from "@calcom/lib/delegationCredential/server";
5454
import { ErrorCode } from "@calcom/lib/errorCodes";
55+
import type { ErrorWithCode } from "@calcom/lib/errors";
5556
import { getErrorFromUnknown } from "@calcom/lib/errors";
5657
import { getEventName, updateHostInEventName } from "@calcom/lib/event";
5758
import { extractBaseEmail } from "@calcom/lib/extract-base-email";
@@ -454,11 +455,22 @@ async function handler(
454455
await checkIfBookerEmailIsBlocked({ loggedInUserId: userId, bookerEmail });
455456

456457
if (!rawBookingData.rescheduleUid) {
457-
await checkActiveBookingsLimitForBooker({
458-
eventTypeId,
459-
maxActiveBookingsPerBooker: eventType.maxActiveBookingsPerBooker,
460-
bookerEmail,
461-
});
458+
try {
459+
await checkActiveBookingsLimitForBooker({
460+
eventTypeId,
461+
maxActiveBookingsPerBooker: eventType.maxActiveBookingsPerBooker,
462+
bookerEmail,
463+
offerToRescheduleLastBooking: eventType.maxActiveBookingPerBookerOfferReschedule,
464+
});
465+
} catch (err) {
466+
const error = err as ErrorWithCode;
467+
468+
throw new HttpError({
469+
statusCode: 400,
470+
message: error.message,
471+
data: error.data,
472+
});
473+
}
462474
}
463475

464476
if (isEventTypeLoggingEnabled({ eventTypeId, usernameOrTeamName: reqBody.user })) {

packages/features/bookings/lib/handleNewBooking/checkActiveBookingsLimitForBooker.ts

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,44 @@
1+
import { ErrorCode } from "@calcom/lib/errorCodes";
2+
import { ErrorWithCode } from "@calcom/lib/errors";
13
import prisma from "@calcom/prisma";
24
import { BookingStatus } from "@calcom/prisma/enums";
35

46
export const checkActiveBookingsLimitForBooker = async ({
57
eventTypeId,
68
maxActiveBookingsPerBooker,
79
bookerEmail,
10+
offerToRescheduleLastBooking,
811
}: {
912
eventTypeId: number;
1013
maxActiveBookingsPerBooker: number | null;
1114
bookerEmail: string;
15+
offerToRescheduleLastBooking: boolean;
1216
}) => {
1317
if (!maxActiveBookingsPerBooker) {
1418
return;
1519
}
1620

21+
if (offerToRescheduleLastBooking) {
22+
await checkActiveBookingsLimitAndOfferReschedule({
23+
eventTypeId,
24+
maxActiveBookingsPerBooker,
25+
bookerEmail,
26+
});
27+
} else {
28+
await checkActiveBookingsLimit({ eventTypeId, maxActiveBookingsPerBooker, bookerEmail });
29+
}
30+
};
31+
32+
/** If we don't need the last record then we should just use COUNT */
33+
const checkActiveBookingsLimit = async ({
34+
eventTypeId,
35+
maxActiveBookingsPerBooker,
36+
bookerEmail,
37+
}: {
38+
eventTypeId: number;
39+
maxActiveBookingsPerBooker: number;
40+
bookerEmail: string;
41+
}) => {
1742
const bookingsCount = await prisma.booking.count({
1843
where: {
1944
eventTypeId,
@@ -32,8 +57,61 @@ export const checkActiveBookingsLimitForBooker = async ({
3257
});
3358

3459
if (bookingsCount >= maxActiveBookingsPerBooker) {
35-
throw new Error("Booker maximum active booking limit exceeded");
60+
throw new ErrorWithCode(ErrorCode.BookerLimitExceeded, ErrorCode.BookerLimitExceeded);
3661
}
62+
};
63+
64+
const checkActiveBookingsLimitAndOfferReschedule = async ({
65+
eventTypeId,
66+
maxActiveBookingsPerBooker,
67+
bookerEmail,
68+
}: {
69+
eventTypeId: number;
70+
maxActiveBookingsPerBooker: number;
71+
bookerEmail: string;
72+
}) => {
73+
const bookingsCount = await prisma.booking.findMany({
74+
where: {
75+
eventTypeId,
76+
startTime: {
77+
gte: new Date(),
78+
},
79+
status: {
80+
in: [BookingStatus.ACCEPTED],
81+
},
82+
attendees: {
83+
some: {
84+
email: bookerEmail,
85+
},
86+
},
87+
},
88+
orderBy: {
89+
startTime: "desc",
90+
},
91+
take: maxActiveBookingsPerBooker,
92+
select: {
93+
uid: true,
94+
startTime: true,
95+
attendees: {
96+
select: {
97+
name: true,
98+
email: true,
99+
},
100+
},
101+
},
102+
});
37103

38-
return;
104+
const lastBooking = bookingsCount[bookingsCount.length - 1];
105+
106+
if (bookingsCount.length >= maxActiveBookingsPerBooker) {
107+
throw new ErrorWithCode(
108+
ErrorCode.BookerLimitExceededReschedule,
109+
ErrorCode.BookerLimitExceededReschedule,
110+
{
111+
rescheduleUid: lastBooking.uid,
112+
startTime: lastBooking.startTime,
113+
attendees: lastBooking.attendees,
114+
}
115+
);
116+
}
39117
};

packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ export const getEventTypesFromDB = async (eventTypeId: number) => {
6666
includeNoShowInRRCalculation: true,
6767
minimumBookingNotice: true,
6868
maxActiveBookingsPerBooker: true,
69+
maxActiveBookingPerBookerOfferReschedule: true,
6970
userId: true,
7071
price: true,
7172
currency: true,

0 commit comments

Comments
 (0)