Skip to content

Commit a0e5e03

Browse files
feat: Add async spam check and decoy booking response (#24326)
* feat: Add async spam check integration and decoy booking response - Integrate SpamCheckService with handleNewBooking workflow - Implement parallel spam check execution for minimal performance impact - Add decoy booking response with localStorage-based success page - Extract organization ID from event type for org-specific blocking - Add comprehensive test coverage for spam detection scenarios - Create reusable components for booking success cards - Implement fail-open behavior to never block legitimate bookings This builds on the spam blocker DI infrastructure from PR #24040 by adding the actual integration into the booking flow and implementing the decoy response mechanism to avoid revealing spam detection to malicious actors. Related: #24040 Co-Authored-By: [email protected] <[email protected]> * Do checks in paralle * Fix leaking host name in title * Reduce expoiry time localstorage --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent 09ee39a commit a0e5e03

File tree

13 files changed

+1409
-34
lines changed

13 files changed

+1409
-34
lines changed
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"use client";
2+
3+
import { useParams } from "next/navigation";
4+
5+
import dayjs from "@calcom/dayjs";
6+
import { DecoyBookingSuccessCard } from "@calcom/features/bookings/Booker/components/DecoyBookingSuccessCard";
7+
import { useDecoyBooking } from "@calcom/features/bookings/Booker/components/hooks/useDecoyBooking";
8+
9+
export default function BookingSuccessful() {
10+
const params = useParams();
11+
12+
const uid = params?.uid as string;
13+
const bookingData = useDecoyBooking(uid);
14+
15+
if (!bookingData) {
16+
return null;
17+
}
18+
19+
const { booking } = bookingData;
20+
21+
// Format the data for the BookingSuccessCard
22+
const startTime = booking.startTime ? dayjs(booking.startTime) : null;
23+
const endTime = booking.endTime ? dayjs(booking.endTime) : null;
24+
const timeZone = booking.booker?.timeZone || booking.host?.timeZone || dayjs.tz.guess();
25+
26+
const formattedDate = startTime ? startTime.tz(timeZone).format("dddd, MMMM D, YYYY") : "";
27+
const formattedTime = startTime ? startTime.tz(timeZone).format("h:mm A") : "";
28+
const formattedEndTime = endTime ? endTime.tz(timeZone).format("h:mm A") : "";
29+
const formattedTimeZone = timeZone;
30+
31+
const hostName = booking.host?.name || null;
32+
const hostEmail = null; // Email not stored for spam decoy bookings
33+
const attendeeName = booking.booker?.name || null;
34+
const attendeeEmail = booking.booker?.email || null;
35+
36+
return (
37+
<DecoyBookingSuccessCard
38+
title={booking.title || "Booking"}
39+
formattedDate={formattedDate}
40+
formattedTime={formattedTime}
41+
endTime={formattedEndTime}
42+
formattedTimeZone={formattedTimeZone}
43+
hostName={hostName}
44+
hostEmail={hostEmail}
45+
attendeeName={attendeeName}
46+
attendeeEmail={attendeeEmail}
47+
location={booking.location || null}
48+
/>
49+
);
50+
}

apps/web/test/utils/bookingScenario/bookingScenario.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2001,6 +2001,10 @@ export async function mockCalendarToHaveNoBusySlots(
20012001
return await mockCalendar(metadataLookupKey, { ...calendarData, busySlots: [] });
20022002
}
20032003

2004+
export async function mockCalendarToCrashOnGetAvailability(metadataLookupKey: keyof typeof appStoreMetadata) {
2005+
return await mockCalendar(metadataLookupKey, { getAvailabilityCrash: true });
2006+
}
2007+
20042008
export async function mockCalendarToCrashOnCreateEvent(metadataLookupKey: keyof typeof appStoreMetadata) {
20052009
return await mockCalendar(metadataLookupKey, { creationCrash: true });
20062010
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { useLocale } from "@calcom/lib/hooks/useLocale";
2+
import { Badge } from "@calcom/ui/components/badge";
3+
import { Icon } from "@calcom/ui/components/icon";
4+
5+
export interface DecoyBookingSuccessCardProps {
6+
title: string;
7+
formattedDate: string;
8+
formattedTime: string;
9+
endTime: string;
10+
formattedTimeZone: string;
11+
hostName: string | null;
12+
hostEmail: string | null;
13+
attendeeName: string | null;
14+
attendeeEmail: string | null;
15+
location: string | null;
16+
}
17+
18+
export function DecoyBookingSuccessCard({
19+
title,
20+
formattedDate,
21+
formattedTime,
22+
endTime,
23+
formattedTimeZone,
24+
hostName,
25+
hostEmail,
26+
attendeeName,
27+
attendeeEmail,
28+
location,
29+
}: DecoyBookingSuccessCardProps) {
30+
const { t } = useLocale();
31+
32+
return (
33+
<div className="h-screen">
34+
<main className="mx-auto max-w-3xl">
35+
<div className="overflow-y-auto">
36+
<div className="flex items-end justify-center px-4 pb-20 pt-4 text-center sm:flex sm:p-0">
37+
<div className="main inset-0 my-4 flex flex-col transition-opacity sm:my-0" aria-hidden="true">
38+
<div
39+
className="bg-default dark:bg-muted border-booker border-booker-width inline-block transform overflow-hidden rounded-lg px-8 pb-4 pt-5 text-left align-bottom transition-all sm:my-8 sm:w-full sm:max-w-xl sm:py-8 sm:align-middle"
40+
role="dialog"
41+
aria-modal="true"
42+
aria-labelledby="modal-headline">
43+
<div>
44+
<div className="bg-success mx-auto flex h-12 w-12 items-center justify-center rounded-full">
45+
<Icon name="check" className="h-5 w-5 text-green-600 dark:text-green-400" />
46+
</div>
47+
</div>
48+
<div className="mb-8 mt-6 text-center last:mb-0">
49+
<h3 className="text-emphasis text-2xl font-semibold leading-6" id="modal-headline">
50+
{t("meeting_is_scheduled")}
51+
</h3>
52+
53+
<div className="mt-3">
54+
<p className="text-default">{t("emailed_you_and_any_other_attendees")}</p>
55+
</div>
56+
57+
<div className="border-subtle text-default mt-8 grid grid-cols-3 gap-x-4 border-t pt-8 text-left rtl:text-right sm:gap-x-0">
58+
<div className="font-medium">{t("what")}</div>
59+
<div className="col-span-2 mb-6 last:mb-0">{title}</div>
60+
61+
{formattedDate && (
62+
<>
63+
<div className="font-medium">{t("when")}</div>
64+
<div className="col-span-2 mb-6 last:mb-0">
65+
{formattedDate}
66+
{formattedTime && (
67+
<>
68+
<br />
69+
{formattedTime}
70+
{endTime && ` - ${endTime}`}
71+
{formattedTimeZone && (
72+
<span className="text-bookinglight"> ({formattedTimeZone})</span>
73+
)}
74+
</>
75+
)}
76+
</div>
77+
</>
78+
)}
79+
80+
<div className="font-medium">{t("who")}</div>
81+
<div className="col-span-2 last:mb-0">
82+
{hostName && (
83+
<div className="mb-3">
84+
<div>
85+
<span className="mr-2">{hostName}</span>
86+
<Badge variant="blue">{t("Host")}</Badge>
87+
</div>
88+
{hostEmail && <p className="text-default">{hostEmail}</p>}
89+
</div>
90+
)}
91+
{attendeeName && (
92+
<div className="mb-3 last:mb-0">
93+
<p>{attendeeName}</p>
94+
{attendeeEmail && <p>{attendeeEmail}</p>}
95+
</div>
96+
)}
97+
</div>
98+
99+
{location && (
100+
<>
101+
<div className="mt-3 font-medium">{t("where")}</div>
102+
<div className="col-span-2 mt-3">{t("web_conferencing_details_to_follow")}</div>
103+
</>
104+
)}
105+
</div>
106+
</div>
107+
</div>
108+
</div>
109+
</div>
110+
</div>
111+
</main>
112+
</div>
113+
);
114+
}
115+

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

Lines changed: 39 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,14 @@ import dayjs from "@calcom/dayjs";
1111
import { sdkActionManager } from "@calcom/embed-core/embed-iframe";
1212
import { useBookerStoreContext } from "@calcom/features/bookings/Booker/BookerStoreProvider";
1313
import { updateQueryParam, getQueryParam } from "@calcom/features/bookings/Booker/utils/query-param";
14-
import { createBooking, createRecurringBooking, createInstantBooking } from "@calcom/features/bookings/lib";
14+
import { storeDecoyBooking } from "@calcom/features/bookings/lib/client/decoyBookingStore";
15+
import { createBooking } from "@calcom/features/bookings/lib/create-booking";
16+
import { createInstantBooking } from "@calcom/features/bookings/lib/create-instant-booking";
17+
import { createRecurringBooking } from "@calcom/features/bookings/lib/create-recurring-booking";
1518
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
1619
import type { BookerEvent } from "@calcom/features/bookings/types";
1720
import { getFullName } from "@calcom/features/form-builder/utils";
18-
import { useBookingSuccessRedirect } from "@calcom/features/bookings/lib/bookingSuccessRedirect";
21+
import { useBookingSuccessRedirect } from "../../../lib/bookingSuccessRedirect";
1922
import { ErrorCode } from "@calcom/lib/errorCodes";
2023
import { useLocale } from "@calcom/lib/hooks/useLocale";
2124
import { localStorage } from "@calcom/lib/webstorage";
@@ -165,13 +168,7 @@ const storeInLocalStorage = ({
165168
localStorage.setItem(STORAGE_KEY, value);
166169
};
167170

168-
export const useBookings = ({
169-
event,
170-
hashedLink,
171-
bookingForm,
172-
metadata,
173-
isBookingDryRun,
174-
}: IUseBookings) => {
171+
export const useBookings = ({ event, hashedLink, bookingForm, metadata, isBookingDryRun }: IUseBookings) => {
175172
const router = useRouter();
176173
const eventSlug = useBookerStoreContext((state) => state.eventSlug);
177174
const eventTypeId = useBookerStoreContext((state) => state.eventId);
@@ -248,7 +245,7 @@ export const useBookings = ({
248245
} else {
249246
showToast(t("something_went_wrong_on_our_end"), "error");
250247
}
251-
} catch (err) {
248+
} catch {
252249
showToast(t("something_went_wrong_on_our_end"), "error");
253250
}
254251
},
@@ -259,12 +256,6 @@ export const useBookings = ({
259256
mutationFn: createBooking,
260257
onSuccess: (booking) => {
261258
if (booking.isDryRun) {
262-
const validDuration = event.data?.isDynamic
263-
? duration || event.data?.length
264-
: duration && event.data?.metadata?.multipleDuration?.includes(duration)
265-
? duration
266-
: event.data?.length;
267-
268259
if (isRescheduling) {
269260
sdkActionManager?.fire(
270261
"dryRunRescheduleBookingSuccessfulV2",
@@ -286,6 +277,28 @@ export const useBookings = ({
286277
router.push("/booking/dry-run-successful");
287278
return;
288279
}
280+
281+
if ("isShortCircuitedBooking" in booking && booking.isShortCircuitedBooking) {
282+
if (!booking.uid) {
283+
console.error("Decoy booking missing uid");
284+
return;
285+
}
286+
287+
const bookingData = {
288+
uid: booking.uid,
289+
title: booking.title ?? null,
290+
startTime: booking.startTime,
291+
endTime: booking.endTime,
292+
booker: booking.attendees?.[0] ?? null,
293+
host: booking.user ?? null,
294+
location: booking.location ?? null,
295+
};
296+
297+
storeDecoyBooking(bookingData);
298+
router.push(`/booking-successful/${booking.uid}`);
299+
return;
300+
}
301+
289302
const { uid, paymentUid } = booking;
290303
const fullName = getFullName(bookingForm.getValues("responses.name"));
291304

@@ -380,9 +393,10 @@ export const useBookings = ({
380393
: event?.data?.forwardParamsSuccessRedirect,
381394
});
382395
},
383-
onError: (err, _, ctx) => {
384-
// eslint-disable-next-line @calcom/eslint/no-scroll-into-view-embed -- It is only called when user takes an action in embed
385-
bookerFormErrorRef && bookerFormErrorRef.current?.scrollIntoView({ behavior: "smooth" });
396+
onError: (err) => {
397+
if (bookerFormErrorRef?.current) {
398+
bookerFormErrorRef.current.scrollIntoView({ behavior: "smooth" });
399+
}
386400

387401
const error = err as Error & {
388402
data: { rescheduleUid: string; startTime: string; attendees: string[] };
@@ -414,10 +428,11 @@ export const useBookings = ({
414428
updateQueryParam("bookingId", responseData.bookingId);
415429
setExpiryTime(responseData.expires);
416430
},
417-
onError: (err, _, ctx) => {
431+
onError: (err) => {
418432
console.error("Error creating instant booking", err);
419-
// eslint-disable-next-line @calcom/eslint/no-scroll-into-view-embed -- It is only called when user takes an action in embed
420-
bookerFormErrorRef && bookerFormErrorRef.current?.scrollIntoView({ behavior: "smooth" });
433+
if (bookerFormErrorRef?.current) {
434+
bookerFormErrorRef.current.scrollIntoView({ behavior: "smooth" });
435+
}
421436
},
422437
});
423438

@@ -513,15 +528,10 @@ export const useBookings = ({
513528
bookingForm,
514529
hashedLink,
515530
metadata,
516-
handleInstantBooking: (
517-
variables: Parameters<typeof createInstantBookingMutation.mutate>[0]
518-
) => {
531+
handleInstantBooking: (variables: Parameters<typeof createInstantBookingMutation.mutate>[0]) => {
519532
const remaining = getInstantCooldownRemainingMs(eventTypeId);
520533
if (remaining > 0) {
521-
showToast(
522-
t("please_try_again_later_or_book_another_slot"),
523-
"error"
524-
);
534+
showToast(t("please_try_again_later_or_book_another_slot"), "error");
525535
return;
526536
}
527537
createInstantBookingMutation.mutate(variables);
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
"use client";
2+
3+
import { useRouter } from "next/navigation";
4+
import { useEffect, useState } from "react";
5+
6+
import { getDecoyBooking } from "@calcom/features/bookings/lib/client/decoyBookingStore";
7+
import type { DecoyBookingData } from "@calcom/features/bookings/lib/client/decoyBookingStore";
8+
9+
/**
10+
* Hook to retrieve and manage decoy booking data from localStorage
11+
* @param uid - The booking uid
12+
* @returns The booking data or null if not found/expired
13+
*/
14+
export function useDecoyBooking(uid: string) {
15+
const router = useRouter();
16+
const [bookingData, setBookingData] = useState<DecoyBookingData | null>(null);
17+
18+
useEffect(() => {
19+
const data = getDecoyBooking(uid);
20+
21+
if (!data) {
22+
router.push("/404");
23+
return;
24+
}
25+
26+
setBookingData(data);
27+
}, [uid, router]);
28+
29+
return bookingData;
30+
}
31+

0 commit comments

Comments
 (0)