diff --git a/apps/web/app/(booking-page-wrapper)/booking-successful/[uid]/page.tsx b/apps/web/app/(booking-page-wrapper)/booking-successful/[uid]/page.tsx
new file mode 100644
index 00000000000000..357918cb63b0fc
--- /dev/null
+++ b/apps/web/app/(booking-page-wrapper)/booking-successful/[uid]/page.tsx
@@ -0,0 +1,50 @@
+"use client";
+
+import { useParams } from "next/navigation";
+
+import dayjs from "@calcom/dayjs";
+import { DecoyBookingSuccessCard } from "@calcom/features/bookings/Booker/components/DecoyBookingSuccessCard";
+import { useDecoyBooking } from "@calcom/features/bookings/Booker/components/hooks/useDecoyBooking";
+
+export default function BookingSuccessful() {
+ const params = useParams();
+
+ const uid = params?.uid as string;
+ const bookingData = useDecoyBooking(uid);
+
+ if (!bookingData) {
+ return null;
+ }
+
+ const { booking } = bookingData;
+
+ // Format the data for the BookingSuccessCard
+ const startTime = booking.startTime ? dayjs(booking.startTime) : null;
+ const endTime = booking.endTime ? dayjs(booking.endTime) : null;
+ const timeZone = booking.booker?.timeZone || booking.host?.timeZone || dayjs.tz.guess();
+
+ const formattedDate = startTime ? startTime.tz(timeZone).format("dddd, MMMM D, YYYY") : "";
+ const formattedTime = startTime ? startTime.tz(timeZone).format("h:mm A") : "";
+ const formattedEndTime = endTime ? endTime.tz(timeZone).format("h:mm A") : "";
+ const formattedTimeZone = timeZone;
+
+ const hostName = booking.host?.name || null;
+ const hostEmail = null; // Email not stored for spam decoy bookings
+ const attendeeName = booking.booker?.name || null;
+ const attendeeEmail = booking.booker?.email || null;
+
+ return (
+
+ );
+}
diff --git a/apps/web/test/utils/bookingScenario/bookingScenario.ts b/apps/web/test/utils/bookingScenario/bookingScenario.ts
index d261c3bc63b841..0b74837371cfaa 100644
--- a/apps/web/test/utils/bookingScenario/bookingScenario.ts
+++ b/apps/web/test/utils/bookingScenario/bookingScenario.ts
@@ -2001,6 +2001,10 @@ export async function mockCalendarToHaveNoBusySlots(
return await mockCalendar(metadataLookupKey, { ...calendarData, busySlots: [] });
}
+export async function mockCalendarToCrashOnGetAvailability(metadataLookupKey: keyof typeof appStoreMetadata) {
+ return await mockCalendar(metadataLookupKey, { getAvailabilityCrash: true });
+}
+
export async function mockCalendarToCrashOnCreateEvent(metadataLookupKey: keyof typeof appStoreMetadata) {
return await mockCalendar(metadataLookupKey, { creationCrash: true });
}
diff --git a/packages/features/bookings/Booker/components/DecoyBookingSuccessCard.tsx b/packages/features/bookings/Booker/components/DecoyBookingSuccessCard.tsx
new file mode 100644
index 00000000000000..81021b47ce548d
--- /dev/null
+++ b/packages/features/bookings/Booker/components/DecoyBookingSuccessCard.tsx
@@ -0,0 +1,115 @@
+import { useLocale } from "@calcom/lib/hooks/useLocale";
+import { Badge } from "@calcom/ui/components/badge";
+import { Icon } from "@calcom/ui/components/icon";
+
+export interface DecoyBookingSuccessCardProps {
+ title: string;
+ formattedDate: string;
+ formattedTime: string;
+ endTime: string;
+ formattedTimeZone: string;
+ hostName: string | null;
+ hostEmail: string | null;
+ attendeeName: string | null;
+ attendeeEmail: string | null;
+ location: string | null;
+}
+
+export function DecoyBookingSuccessCard({
+ title,
+ formattedDate,
+ formattedTime,
+ endTime,
+ formattedTimeZone,
+ hostName,
+ hostEmail,
+ attendeeName,
+ attendeeEmail,
+ location,
+}: DecoyBookingSuccessCardProps) {
+ const { t } = useLocale();
+
+ return (
+
+
+
+
+
+
+
+
+
+ {t("meeting_is_scheduled")}
+
+
+
+
{t("emailed_you_and_any_other_attendees")}
+
+
+
+
{t("what")}
+
{title}
+
+ {formattedDate && (
+ <>
+
{t("when")}
+
+ {formattedDate}
+ {formattedTime && (
+ <>
+
+ {formattedTime}
+ {endTime && ` - ${endTime}`}
+ {formattedTimeZone && (
+ ({formattedTimeZone})
+ )}
+ >
+ )}
+
+ >
+ )}
+
+
{t("who")}
+
+ {hostName && (
+
+
+ {hostName}
+ {t("Host")}
+
+ {hostEmail &&
{hostEmail}
}
+
+ )}
+ {attendeeName && (
+
+
{attendeeName}
+ {attendeeEmail &&
{attendeeEmail}
}
+
+ )}
+
+
+ {location && (
+ <>
+
{t("where")}
+
{t("web_conferencing_details_to_follow")}
+ >
+ )}
+
+
+
+
+
+
+
+
+ );
+}
+
diff --git a/packages/features/bookings/Booker/components/hooks/useBookings.ts b/packages/features/bookings/Booker/components/hooks/useBookings.ts
index 3069ff5a113886..87915e4b624a8e 100644
--- a/packages/features/bookings/Booker/components/hooks/useBookings.ts
+++ b/packages/features/bookings/Booker/components/hooks/useBookings.ts
@@ -11,11 +11,14 @@ import dayjs from "@calcom/dayjs";
import { sdkActionManager } from "@calcom/embed-core/embed-iframe";
import { useBookerStoreContext } from "@calcom/features/bookings/Booker/BookerStoreProvider";
import { updateQueryParam, getQueryParam } from "@calcom/features/bookings/Booker/utils/query-param";
-import { createBooking, createRecurringBooking, createInstantBooking } from "@calcom/features/bookings/lib";
+import { storeDecoyBooking } from "@calcom/features/bookings/lib/client/decoyBookingStore";
+import { createBooking } from "@calcom/features/bookings/lib/create-booking";
+import { createInstantBooking } from "@calcom/features/bookings/lib/create-instant-booking";
+import { createRecurringBooking } from "@calcom/features/bookings/lib/create-recurring-booking";
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
import type { BookerEvent } from "@calcom/features/bookings/types";
import { getFullName } from "@calcom/features/form-builder/utils";
-import { useBookingSuccessRedirect } from "@calcom/features/bookings/lib/bookingSuccessRedirect";
+import { useBookingSuccessRedirect } from "../../../lib/bookingSuccessRedirect";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { localStorage } from "@calcom/lib/webstorage";
@@ -165,13 +168,7 @@ const storeInLocalStorage = ({
localStorage.setItem(STORAGE_KEY, value);
};
-export const useBookings = ({
- event,
- hashedLink,
- bookingForm,
- metadata,
- isBookingDryRun,
-}: IUseBookings) => {
+export const useBookings = ({ event, hashedLink, bookingForm, metadata, isBookingDryRun }: IUseBookings) => {
const router = useRouter();
const eventSlug = useBookerStoreContext((state) => state.eventSlug);
const eventTypeId = useBookerStoreContext((state) => state.eventId);
@@ -248,7 +245,7 @@ export const useBookings = ({
} else {
showToast(t("something_went_wrong_on_our_end"), "error");
}
- } catch (err) {
+ } catch {
showToast(t("something_went_wrong_on_our_end"), "error");
}
},
@@ -259,12 +256,6 @@ export const useBookings = ({
mutationFn: createBooking,
onSuccess: (booking) => {
if (booking.isDryRun) {
- const validDuration = event.data?.isDynamic
- ? duration || event.data?.length
- : duration && event.data?.metadata?.multipleDuration?.includes(duration)
- ? duration
- : event.data?.length;
-
if (isRescheduling) {
sdkActionManager?.fire(
"dryRunRescheduleBookingSuccessfulV2",
@@ -286,6 +277,28 @@ export const useBookings = ({
router.push("/booking/dry-run-successful");
return;
}
+
+ if ("isShortCircuitedBooking" in booking && booking.isShortCircuitedBooking) {
+ if (!booking.uid) {
+ console.error("Decoy booking missing uid");
+ return;
+ }
+
+ const bookingData = {
+ uid: booking.uid,
+ title: booking.title ?? null,
+ startTime: booking.startTime,
+ endTime: booking.endTime,
+ booker: booking.attendees?.[0] ?? null,
+ host: booking.user ?? null,
+ location: booking.location ?? null,
+ };
+
+ storeDecoyBooking(bookingData);
+ router.push(`/booking-successful/${booking.uid}`);
+ return;
+ }
+
const { uid, paymentUid } = booking;
const fullName = getFullName(bookingForm.getValues("responses.name"));
@@ -380,9 +393,10 @@ export const useBookings = ({
: event?.data?.forwardParamsSuccessRedirect,
});
},
- onError: (err, _, ctx) => {
- // eslint-disable-next-line @calcom/eslint/no-scroll-into-view-embed -- It is only called when user takes an action in embed
- bookerFormErrorRef && bookerFormErrorRef.current?.scrollIntoView({ behavior: "smooth" });
+ onError: (err) => {
+ if (bookerFormErrorRef?.current) {
+ bookerFormErrorRef.current.scrollIntoView({ behavior: "smooth" });
+ }
const error = err as Error & {
data: { rescheduleUid: string; startTime: string; attendees: string[] };
@@ -414,10 +428,11 @@ export const useBookings = ({
updateQueryParam("bookingId", responseData.bookingId);
setExpiryTime(responseData.expires);
},
- onError: (err, _, ctx) => {
+ onError: (err) => {
console.error("Error creating instant booking", err);
- // eslint-disable-next-line @calcom/eslint/no-scroll-into-view-embed -- It is only called when user takes an action in embed
- bookerFormErrorRef && bookerFormErrorRef.current?.scrollIntoView({ behavior: "smooth" });
+ if (bookerFormErrorRef?.current) {
+ bookerFormErrorRef.current.scrollIntoView({ behavior: "smooth" });
+ }
},
});
@@ -513,15 +528,10 @@ export const useBookings = ({
bookingForm,
hashedLink,
metadata,
- handleInstantBooking: (
- variables: Parameters[0]
- ) => {
+ handleInstantBooking: (variables: Parameters[0]) => {
const remaining = getInstantCooldownRemainingMs(eventTypeId);
if (remaining > 0) {
- showToast(
- t("please_try_again_later_or_book_another_slot"),
- "error"
- );
+ showToast(t("please_try_again_later_or_book_another_slot"), "error");
return;
}
createInstantBookingMutation.mutate(variables);
diff --git a/packages/features/bookings/Booker/components/hooks/useDecoyBooking.ts b/packages/features/bookings/Booker/components/hooks/useDecoyBooking.ts
new file mode 100644
index 00000000000000..39356f04ba243a
--- /dev/null
+++ b/packages/features/bookings/Booker/components/hooks/useDecoyBooking.ts
@@ -0,0 +1,31 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { useEffect, useState } from "react";
+
+import { getDecoyBooking } from "@calcom/features/bookings/lib/client/decoyBookingStore";
+import type { DecoyBookingData } from "@calcom/features/bookings/lib/client/decoyBookingStore";
+
+/**
+ * Hook to retrieve and manage decoy booking data from localStorage
+ * @param uid - The booking uid
+ * @returns The booking data or null if not found/expired
+ */
+export function useDecoyBooking(uid: string) {
+ const router = useRouter();
+ const [bookingData, setBookingData] = useState(null);
+
+ useEffect(() => {
+ const data = getDecoyBooking(uid);
+
+ if (!data) {
+ router.push("/404");
+ return;
+ }
+
+ setBookingData(data);
+ }, [uid, router]);
+
+ return bookingData;
+}
+
diff --git a/packages/features/bookings/components/BookingSuccessCard.tsx b/packages/features/bookings/components/BookingSuccessCard.tsx
new file mode 100644
index 00000000000000..1e14079b48a18c
--- /dev/null
+++ b/packages/features/bookings/components/BookingSuccessCard.tsx
@@ -0,0 +1,114 @@
+import { useLocale } from "@calcom/lib/hooks/useLocale";
+import { Badge } from "@calcom/ui/components/badge";
+import { Icon } from "@calcom/ui/components/icon";
+
+export interface BookingSuccessCardProps {
+ title: string;
+ formattedDate: string;
+ formattedTime: string;
+ endTime: string;
+ formattedTimeZone: string;
+ hostName: string | null;
+ hostEmail: string | null;
+ attendeeName: string | null;
+ attendeeEmail: string | null;
+ location: string | null;
+}
+
+export function BookingSuccessCard({
+ title,
+ formattedDate,
+ formattedTime,
+ endTime,
+ formattedTimeZone,
+ hostName,
+ hostEmail,
+ attendeeName,
+ attendeeEmail,
+ location,
+}: BookingSuccessCardProps) {
+ const { t } = useLocale();
+
+ return (
+
+
+
+
+
+
+
+
+
+ {t("meeting_is_scheduled")}
+
+
+
+
{t("emailed_you_and_any_other_attendees")}
+
+
+
+
{t("what")}
+
{title}
+
+ {formattedDate && (
+ <>
+
{t("when")}
+
+ {formattedDate}
+ {formattedTime && (
+ <>
+
+ {formattedTime}
+ {endTime && ` - ${endTime}`}
+ {formattedTimeZone && (
+ ({formattedTimeZone})
+ )}
+ >
+ )}
+
+ >
+ )}
+
+
{t("who")}
+
+ {hostName && (
+
+
+ {hostName}
+ {t("Host")}
+
+ {hostEmail &&
{hostEmail}
}
+
+ )}
+ {attendeeName && (
+
+
{attendeeName}
+ {attendeeEmail &&
{attendeeEmail}
}
+
+ )}
+
+
+ {location && (
+ <>
+
{t("where")}
+
{t("web_conferencing_details_to_follow")}
+ >
+ )}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/packages/features/bookings/lib/client/decoyBookingStore.ts b/packages/features/bookings/lib/client/decoyBookingStore.ts
new file mode 100644
index 00000000000000..88a387f0fc7129
--- /dev/null
+++ b/packages/features/bookings/lib/client/decoyBookingStore.ts
@@ -0,0 +1,86 @@
+import { localStorage } from "@calcom/lib/webstorage";
+
+const BOOKING_SUCCESS_STORAGE_KEY_PREFIX = "cal.successfulBooking";
+
+interface DecoyBookingData {
+ booking: {
+ uid: string;
+ title: string | null;
+ startTime: string;
+ endTime: string;
+ booker: { name: string; timeZone?: string; email: string } | null;
+ host: { name: string; timeZone?: string } | null;
+ location: string | null;
+ };
+ timestamp: number;
+}
+
+function getStorageKey(uid: string): string {
+ return `${BOOKING_SUCCESS_STORAGE_KEY_PREFIX}.${uid}`;
+}
+
+/**
+ * Stores decoy booking data in localStorage using the booking's uid
+ * @param booking - The booking data to store (must include uid)
+ */
+export function storeDecoyBooking(booking: Record & { uid: string }): void {
+ const bookingSuccessData = {
+ booking,
+ timestamp: Date.now(),
+ };
+ const storageKey = getStorageKey(booking.uid);
+ localStorage.setItem(storageKey, JSON.stringify(bookingSuccessData));
+}
+
+/**
+ * Retrieves decoy booking data from localStorage
+ * @param uid - The booking uid
+ * @returns The stored booking data or null if not found or expired
+ */
+export function getDecoyBooking(uid: string): DecoyBookingData | null {
+ if (!uid) {
+ return null;
+ }
+
+ const storageKey = getStorageKey(uid);
+ const dataStr = localStorage.getItem(storageKey);
+
+ if (!dataStr) {
+ return null;
+ }
+
+ try {
+ const data: DecoyBookingData = JSON.parse(dataStr);
+
+ // Check if the data is too old (5 min)
+ const dataAge = Date.now() - data.timestamp;
+ const maxAge = 5 * 60 * 1000; // 5 minutes in milliseconds
+
+ if (dataAge > maxAge) {
+ // Remove the data from localStorage if expired
+ localStorage.removeItem(storageKey);
+ return null;
+ }
+
+ return data;
+ } catch {
+ // If parsing fails, remove the corrupted data
+ localStorage.removeItem(storageKey);
+ return null;
+ }
+}
+
+/**
+ * Removes decoy booking data from localStorage
+ * @param uid - The booking uid
+ */
+export function removeDecoyBooking(uid: string): void {
+ if (!uid) {
+ return;
+ }
+
+ const storageKey = getStorageKey(uid);
+ localStorage.removeItem(storageKey);
+}
+
+export type { DecoyBookingData };
diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts
index 4a436f19146152..7a33982a92ac7f 100644
--- a/packages/features/bookings/lib/handleNewBooking.ts
+++ b/packages/features/bookings/lib/handleNewBooking.ts
@@ -23,7 +23,6 @@ import { scheduleMandatoryReminder } from "@calcom/ee/workflows/lib/reminders/sc
import getICalUID from "@calcom/emails/lib/getICalUID";
import { CalendarEventBuilder } from "@calcom/features/CalendarEventBuilder";
import EventManager, { placeholderCreatedEvent } from "@calcom/features/bookings/lib/EventManager";
-import type { CheckBookingLimitsService } from "@calcom/features/bookings/lib/checkBookingLimits";
import type { BookingDataSchemaGetter } from "@calcom/features/bookings/lib/dto/types";
import type {
CreateRegularBookingData,
@@ -34,16 +33,14 @@ import type { CheckBookingAndDurationLimitsService } from "@calcom/features/book
import { handlePayment } from "@calcom/features/bookings/lib/handlePayment";
import { handleWebhookTrigger } from "@calcom/features/bookings/lib/handleWebhookTrigger";
import { isEventTypeLoggingEnabled } from "@calcom/features/bookings/lib/isEventTypeLoggingEnabled";
-import type { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
import type { CacheService } from "@calcom/features/calendar-cache/lib/getShouldServeCache";
+import { getSpamCheckService } from "@calcom/features/di/watchlist/containers/SpamCheckService.container";
import AssignmentReasonRecorder from "@calcom/features/ee/round-robin/assignmentReason/AssignmentReasonRecorder";
-import { WorkflowRepository } from "@calcom/features/ee/workflows/repositories/WorkflowRepository";
import { getUsernameList } from "@calcom/features/eventtypes/lib/defaultEvents";
import { getEventName, updateHostInEventName } from "@calcom/features/eventtypes/lib/eventNaming";
import type { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { getFullName } from "@calcom/features/form-builder/utils";
import { handleAnalyticsEvents } from "@calcom/features/tasker/tasks/analytics/handleAnalyticsEvents";
-import type { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { UsersRepository } from "@calcom/features/users/users.repository";
import type { GetSubscriberOptions } from "@calcom/features/webhooks/lib/getWebhooks";
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
@@ -63,13 +60,17 @@ import { getBookerBaseUrl } from "@calcom/features/ee/organizations/lib/getBooke
import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType";
import { HttpError } from "@calcom/lib/http-error";
+import type { CheckBookingLimitsService } from "@calcom/features/bookings/lib/checkBookingLimits";
import logger from "@calcom/lib/logger";
import { getPiiFreeCalendarEvent, getPiiFreeEventType } from "@calcom/lib/piiFreeData";
import { safeStringify } from "@calcom/lib/safeStringify";
import { getTranslation } from "@calcom/lib/server/i18n";
import type { PrismaAttributeRepository as AttributeRepository } from "@calcom/lib/server/repository/PrismaAttributeRepository";
+import type { BookingRepository } from "../repositories/BookingRepository";
import type { HostRepository } from "@calcom/lib/server/repository/host";
import type { PrismaOOORepository as OooRepository } from "@calcom/lib/server/repository/ooo";
+import type { UserRepository } from "@calcom/features/users/repositories/UserRepository";
+import { WorkflowRepository } from "@calcom/features/ee/workflows/repositories/WorkflowRepository";
import { HashedLinkService } from "@calcom/lib/server/service/hashedLinkService";
import { WorkflowService } from "@calcom/lib/server/service/workflows";
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
@@ -154,7 +155,9 @@ function getICalSequence(originalRescheduledBooking: BookingType | null) {
return originalRescheduledBooking.iCalSequence + 1;
}
-type CreatedBooking = Booking & { appsStatus?: AppsStatus[]; paymentUid?: string; paymentId?: number };
+type CreatedBooking = Booking & {
+ isShortCircuitedBooking?: boolean;
+} & { appsStatus?: AppsStatus[]; paymentUid?: string; paymentId?: number };
type ReturnTypeCreateBooking = Awaited>;
export const buildDryRunBooking = ({
eventTypeId,
@@ -505,6 +508,12 @@ async function handler(
await checkIfBookerEmailIsBlocked({ loggedInUserId: userId, bookerEmail });
+ const spamCheckService = getSpamCheckService();
+ // Either it is a team event or a managed child event of a managed event
+ const team = eventType.team ?? eventType.parent?.team ?? null;
+ const eventOrganizationId = team?.parentId ?? null;
+ spamCheckService.startCheck({ email: bookerEmail, organizationId: eventOrganizationId });
+
if (!rawBookingData.rescheduleUid) {
await checkActiveBookingsLimitForBooker({
eventTypeId,
@@ -1404,6 +1413,88 @@ async function handler(
organizerUser.id
);
+ const spamCheckResult = await spamCheckService.waitForCheck();
+
+ if (spamCheckResult.isBlocked) {
+ const DECOY_ORGANIZER_NAMES = ["Alex Smith", "Jordan Taylor", "Sam Johnson", "Chris Morgan"];
+ const randomOrganizerName =
+ DECOY_ORGANIZER_NAMES[Math.floor(Math.random() * DECOY_ORGANIZER_NAMES.length)];
+
+ const eventName = getEventName({
+ ...eventNameObject,
+ host: randomOrganizerName,
+ });
+
+ return {
+ id: 0,
+ uid,
+ iCalUID: "",
+ status: BookingStatus.ACCEPTED,
+ eventTypeId: eventType.id,
+ user: {
+ name: randomOrganizerName,
+ timeZone: "UTC",
+ email: null,
+ },
+ userId: null,
+ title: eventName,
+ startTime: new Date(reqBody.start),
+ endTime: new Date(reqBody.end),
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ attendees: [
+ {
+ id: 0,
+ email: bookerEmail,
+ name: fullName,
+ timeZone: reqBody.timeZone,
+ locale: null,
+ phoneNumber: null,
+ bookingId: null,
+ noShow: null,
+ },
+ ],
+ oneTimePassword: null,
+ smsReminderNumber: null,
+ metadata: {},
+ idempotencyKey: null,
+ userPrimaryEmail: null,
+ description: eventType.description || null,
+ customInputs: null,
+ responses: null,
+ location: bookingLocation,
+ paid: false,
+ cancellationReason: null,
+ rejectionReason: null,
+ dynamicEventSlugRef: null,
+ dynamicGroupSlugRef: null,
+ fromReschedule: null,
+ recurringEventId: null,
+ scheduledJobs: [],
+ rescheduledBy: null,
+ destinationCalendarId: null,
+ reassignReason: null,
+ reassignById: null,
+ rescheduled: false,
+ isRecorded: false,
+ iCalSequence: 0,
+ rating: null,
+ ratingFeedback: null,
+ noShowHost: null,
+ cancelledBy: null,
+ creationSource: CreationSource.WEBAPP,
+ references: [],
+ payment: [],
+ isDryRun: false,
+ paymentRequired: false,
+ paymentUid: undefined,
+ luckyUsers: [],
+ paymentId: undefined,
+ seatReferenceUid: undefined,
+ isShortCircuitedBooking: true, // Renamed from isSpamDecoy to avoid exposing spam detection to blocked users
+ }
+ }
+
// For seats, if the booking already exists then we want to add the new attendee to the existing booking
if (eventType.seatsPerTimeSlot) {
const newBooking = await handleSeats({
diff --git a/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts b/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts
index 5c3507ce1d0fc2..26394861bf9ce5 100644
--- a/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts
+++ b/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts
@@ -97,6 +97,7 @@ const getEventTypesFromDBSelect = {
team: {
select: {
id: true,
+ parentId: true,
bookingLimits: true,
includeManagedEventsInLimits: true,
},
diff --git a/packages/features/bookings/lib/handleNewBooking/test/spam-booking.integration-test.ts b/packages/features/bookings/lib/handleNewBooking/test/spam-booking.integration-test.ts
new file mode 100644
index 00000000000000..9f33cf0f080d6e
--- /dev/null
+++ b/packages/features/bookings/lib/handleNewBooking/test/spam-booking.integration-test.ts
@@ -0,0 +1,789 @@
+import {
+ createBookingScenario,
+ TestData,
+ getGoogleCalendarCredential,
+ getOrganizer,
+ getBooker,
+ getScenarioData,
+ mockCalendarToHaveNoBusySlots,
+ mockCalendarToCrashOnGetAvailability,
+ BookingLocations,
+ createOrganization,
+} from "@calcom/web/test/utils/bookingScenario/bookingScenario";
+import { prisma } from "@calcom/prisma"
+import { getMockRequestDataForBooking } from "@calcom/web/test/utils/bookingScenario/getMockRequestDataForBooking";
+import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown";
+
+import { describe, expect, vi } from "vitest";
+
+import { WatchlistType, BookingStatus } from "@calcom/prisma/enums";
+import { test } from "@calcom/web/test/fixtures/fixtures";
+
+import { getNewBookingHandler } from "./getNewBookingHandler";
+
+const timeout = process.env.CI ? 5000 : 20000;
+
+const createTestWatchlistEntry = async (overrides: {
+ type: WatchlistType;
+ value: string;
+ organizationId: number | null;
+ action: "BLOCK" | "REPORT";
+}) => {
+ return prisma.watchlist.create({
+ data: {
+ type: overrides.type,
+ value: overrides.value,
+ action: overrides.action,
+ createdById: 0,
+ organizationId: overrides.organizationId,
+ isGlobal: overrides.organizationId !== null ? false : true,
+ },
+ });
+};
+
+const createGlobalWatchlistEntry = async (overrides: {
+ type: WatchlistType;
+ value: string;
+ action: "BLOCK" | "REPORT";
+}) => {
+ return createTestWatchlistEntry({
+ type: overrides.type,
+ value: overrides.value,
+ action: overrides.action,
+ organizationId: null,
+ });
+};
+
+const createOrganizationWatchlistEntry = async (
+ organizationId: number,
+ overrides: {
+ type: WatchlistType;
+ value: string;
+ action: "BLOCK" | "REPORT";
+ }
+) => {
+ return createTestWatchlistEntry({
+ type: overrides.type,
+ value: overrides.value,
+ action: overrides.action,
+ organizationId,
+ });
+};
+
+const expectDecoyBookingResponse = (booking: Record) => {
+ expect(booking).toHaveProperty("isShortCircuitedBooking", true);
+ expect(booking).toHaveProperty("uid");
+ expect(booking.uid).toBeTruthy();
+ expect(booking).toHaveProperty("status", BookingStatus.ACCEPTED);
+ expect(booking).toHaveProperty("id", 0);
+};
+
+const expectNoBookingInDatabase = async (bookerEmail: string) => {
+ const bookings = await prisma.booking.findMany({
+ where: {
+ attendees: {
+ some: {
+ email: bookerEmail,
+ },
+ },
+ },
+ });
+ expect(bookings).toHaveLength(0);
+};
+
+describe("handleNewBooking - Spam Detection", () => {
+ setupAndTeardown();
+
+ describe("Global Watchlist Blocking:", () => {
+ test(
+ "should block booking when email is in global watchlist and return decoy response",
+ async () => {
+ const handleNewBooking = getNewBookingHandler();
+ const blockedEmail = "spammer@example.com";
+
+ const booker = getBooker({
+ email: blockedEmail,
+ name: "Blocked Booker",
+ });
+
+ const organizer = getOrganizer({
+ name: "Organizer",
+ email: "organizer@example.com",
+ id: 101,
+ schedules: [TestData.schedules.IstWorkHours],
+ credentials: [getGoogleCalendarCredential()],
+ selectedCalendars: [TestData.selectedCalendars.google],
+ });
+
+ await createGlobalWatchlistEntry({
+ type: WatchlistType.EMAIL,
+ value: blockedEmail,
+ action: "BLOCK",
+ });
+
+ console.log("watchlists", await prisma.watchlist.findMany());
+
+ await createBookingScenario(
+ getScenarioData({
+ eventTypes: [
+ {
+ id: 1,
+ slotInterval: 30,
+ length: 30,
+ users: [
+ {
+ id: 101,
+ },
+ ],
+ },
+ ],
+ organizer,
+ apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
+ })
+ );
+
+ await mockCalendarToHaveNoBusySlots("googlecalendar", {
+ create: {
+ id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID",
+ },
+ });
+
+ const mockBookingData = getMockRequestDataForBooking({
+ data: {
+ user: organizer.username,
+ eventTypeId: 1,
+ responses: {
+ email: booker.email,
+ name: booker.name,
+ location: { optionValue: "", value: BookingLocations.CalVideo },
+ },
+ },
+ });
+
+ const createdBooking = await handleNewBooking({
+ bookingData: mockBookingData,
+ });
+
+ expectDecoyBookingResponse(createdBooking);
+ expect(createdBooking.attendees[0].email).toBe(blockedEmail);
+ await expectNoBookingInDatabase(blockedEmail);
+ },
+ timeout
+ );
+
+ test(
+ "should fail with there are no available users instead of returning decoy response because spam check happens in parallel with availability check",
+ async () => {
+ const handleNewBooking = getNewBookingHandler();
+ const blockedEmail = "spammer@example.com";
+
+ const booker = getBooker({
+ email: blockedEmail,
+ name: "Blocked Booker",
+ });
+
+ const organizer = getOrganizer({
+ name: "Organizer",
+ email: "organizer@example.com",
+ id: 101,
+ schedules: [TestData.schedules.IstWorkHours],
+ credentials: [getGoogleCalendarCredential()],
+ selectedCalendars: [TestData.selectedCalendars.google],
+ });
+
+ await createGlobalWatchlistEntry({
+ type: WatchlistType.EMAIL,
+ value: blockedEmail,
+ action: "BLOCK",
+ });
+
+ await createBookingScenario(
+ getScenarioData({
+ eventTypes: [
+ {
+ id: 1,
+ slotInterval: 30,
+ length: 30,
+ users: [
+ {
+ id: 101,
+ },
+ ],
+ },
+ ],
+ organizer,
+ apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
+ })
+ );
+
+ await mockCalendarToCrashOnGetAvailability("googlecalendar");
+
+ const mockBookingData = getMockRequestDataForBooking({
+ data: {
+ user: organizer.username,
+ eventTypeId: 1,
+ responses: {
+ email: booker.email,
+ name: booker.name,
+ location: { optionValue: "", value: BookingLocations.CalVideo },
+ },
+ },
+ });
+
+ await expect(async () => {
+ await handleNewBooking({
+ bookingData: mockBookingData,
+ });
+ }).rejects.toThrow("no_available_users_found_error");
+ },
+ timeout
+ );
+
+ test(
+ "should block booking when domain is in global watchlist and return decoy response",
+ async () => {
+ const handleNewBooking = getNewBookingHandler();
+ const blockedDomain = "@globalspammydomain.com";
+ const blockedEmail = `user${blockedDomain}`;
+
+ const booker = getBooker({
+ email: blockedEmail,
+ name: "Global Domain Blocked Booker",
+ });
+
+ const organizer = getOrganizer({
+ name: "Organizer",
+ email: "organizer@example.com",
+ id: 101,
+ schedules: [TestData.schedules.IstWorkHours],
+ credentials: [getGoogleCalendarCredential()],
+ selectedCalendars: [TestData.selectedCalendars.google],
+ });
+
+ await createGlobalWatchlistEntry({
+ type: WatchlistType.DOMAIN,
+ value: blockedDomain,
+ action: "BLOCK",
+ });
+
+ await createBookingScenario(
+ getScenarioData({
+ eventTypes: [
+ {
+ id: 1,
+ slotInterval: 30,
+ length: 30,
+ users: [
+ {
+ id: 101,
+ },
+ ],
+ },
+ ],
+ organizer,
+ apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
+ })
+ );
+
+ await mockCalendarToHaveNoBusySlots("googlecalendar", {
+ create: {
+ id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID",
+ },
+ });
+
+ const mockBookingData = getMockRequestDataForBooking({
+ data: {
+ user: organizer.username,
+ eventTypeId: 1,
+ responses: {
+ email: booker.email,
+ name: booker.name,
+ location: { optionValue: "", value: BookingLocations.CalVideo },
+ },
+ },
+ });
+
+ const createdBooking = await handleNewBooking({
+ bookingData: mockBookingData,
+ });
+
+ expectDecoyBookingResponse(createdBooking);
+ expect(createdBooking.attendees[0].email).toBe(blockedEmail);
+ await expectNoBookingInDatabase(blockedEmail);
+ },
+ timeout
+ );
+
+ test(
+ "should allow booking when spamCheckService.startCheck throws error (fail-open behavior)",
+ async () => {
+ const handleNewBooking = getNewBookingHandler();
+ const bookerEmail = "failopen@example.com";
+
+ const booker = getBooker({
+ email: bookerEmail,
+ name: "Fail Open Booker",
+ });
+
+ const organizer = getOrganizer({
+ name: "Organizer",
+ email: "organizer@example.com",
+ id: 101,
+ schedules: [TestData.schedules.IstWorkHours],
+ credentials: [getGoogleCalendarCredential()],
+ selectedCalendars: [TestData.selectedCalendars.google],
+ });
+
+ await createBookingScenario(
+ getScenarioData({
+ eventTypes: [
+ {
+ id: 1,
+ slotInterval: 30,
+ length: 30,
+ users: [
+ {
+ id: 101,
+ },
+ ],
+ },
+ ],
+ organizer,
+ apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
+ })
+ );
+
+ await mockCalendarToHaveNoBusySlots("googlecalendar", {
+ create: {
+ id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID",
+ },
+ });
+
+ const mockBookingData = getMockRequestDataForBooking({
+ data: {
+ user: organizer.username,
+ eventTypeId: 1,
+ responses: {
+ email: booker.email,
+ name: booker.name,
+ location: { optionValue: "", value: BookingLocations.CalVideo },
+ },
+ },
+ });
+
+ // Mock the SpamCheckService to throw an error during isBlocked check
+ const { getSpamCheckService } = await import(
+ "@calcom/features/di/watchlist/containers/SpamCheckService.container"
+ );
+ const spamCheckService = getSpamCheckService();
+ const originalIsBlocked = spamCheckService["isBlocked"].bind(spamCheckService);
+
+ // Use vi.spyOn to mock the private method
+ const isBlockedSpy = vi.spyOn(spamCheckService as never, "isBlocked");
+ isBlockedSpy.mockRejectedValue(new Error("Database connection failed"));
+
+ try {
+ const createdBooking = await handleNewBooking({
+ bookingData: mockBookingData,
+ });
+
+ // Should NOT be a decoy response - booking should succeed due to fail-open behavior
+ expect(createdBooking).not.toHaveProperty("isShortCircuitedBooking");
+ expect(createdBooking.status).toBe(BookingStatus.ACCEPTED);
+ expect(createdBooking.id).not.toBe(0);
+ expect(createdBooking.attendees[0].email).toBe(bookerEmail);
+ } finally {
+ // Restore original implementation and clean up spy
+ isBlockedSpy.mockRestore();
+ spamCheckService["isBlocked"] = originalIsBlocked;
+ }
+ },
+ timeout
+ );
+ });
+
+ describe("Organization Watchlist Blocking:", () => {
+ test(
+ "should block booking when email is in organization watchlist and return decoy response",
+ async () => {
+ const handleNewBooking = getNewBookingHandler();
+ const blockedEmail = "org-spammer@example.com";
+
+ // Create organization with a team
+ const org = await createOrganization({
+ name: "Test Org",
+ slug: "test-org",
+ withTeam: true,
+ });
+
+ const booker = getBooker({
+ email: blockedEmail,
+ name: "Org Blocked Booker",
+ });
+
+ const organizer = getOrganizer({
+ name: "Organizer",
+ email: "organizer@example.com",
+ id: 101,
+ schedules: [TestData.schedules.IstWorkHours],
+ credentials: [getGoogleCalendarCredential()],
+ selectedCalendars: [TestData.selectedCalendars.google],
+ organizationId: org.id,
+ });
+
+ await createOrganizationWatchlistEntry(org.id, {
+ type: WatchlistType.EMAIL,
+ value: blockedEmail,
+ action: "BLOCK",
+ });
+
+ // Use the child team ID for the eventType
+ const teamId = org.children && org.children.length > 0 ? org.children[0].id : null;
+
+ await createBookingScenario(
+ getScenarioData(
+ {
+ eventTypes: [
+ {
+ id: 1,
+ slotInterval: 30,
+ length: 30,
+ teamId,
+ users: [
+ {
+ id: 101,
+ },
+ ],
+ },
+ ],
+ organizer,
+ apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
+ },
+ { id: org.id }
+ )
+ );
+
+ await mockCalendarToHaveNoBusySlots("googlecalendar", {
+ create: {
+ id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID",
+ },
+ });
+
+ const mockBookingData = getMockRequestDataForBooking({
+ data: {
+ user: organizer.username,
+ eventTypeId: 1,
+ responses: {
+ email: booker.email,
+ name: booker.name,
+ location: { optionValue: "", value: BookingLocations.CalVideo },
+ },
+ },
+ });
+
+ const createdBooking = await handleNewBooking({
+ bookingData: mockBookingData,
+ });
+
+ expectDecoyBookingResponse(createdBooking);
+ expect(createdBooking.attendees[0].email).toBe(blockedEmail);
+ await expectNoBookingInDatabase(blockedEmail);
+ },
+ timeout
+ );
+
+ test(
+ "should block booking when domain is in organization watchlist and return decoy response",
+ async () => {
+ const handleNewBooking = getNewBookingHandler();
+ const blockedDomain = "@spammydomain.com";
+ const blockedEmail = `user${blockedDomain}`;
+
+ // Create organization with a team
+ const org = await createOrganization({
+ name: "Test Org",
+ slug: "test-org",
+ withTeam: true,
+ });
+
+ const booker = getBooker({
+ email: blockedEmail,
+ name: "Domain Blocked Booker",
+ });
+
+ const organizer = getOrganizer({
+ name: "Organizer",
+ email: "organizer@example.com",
+ id: 101,
+ schedules: [TestData.schedules.IstWorkHours],
+ credentials: [getGoogleCalendarCredential()],
+ selectedCalendars: [TestData.selectedCalendars.google],
+ organizationId: org.id,
+ });
+
+ await createOrganizationWatchlistEntry(org.id, {
+ type: WatchlistType.DOMAIN,
+ value: blockedDomain,
+ action: "BLOCK",
+ });
+
+ // Use the child team ID for the eventType
+ const teamId = org.children && org.children.length > 0 ? org.children[0].id : null;
+
+ await createBookingScenario(
+ getScenarioData(
+ {
+ eventTypes: [
+ {
+ id: 1,
+ slotInterval: 30,
+ length: 30,
+ teamId,
+ users: [
+ {
+ id: 101,
+ },
+ ],
+ },
+ ],
+ organizer,
+ apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
+ },
+ { id: org.id }
+ )
+ );
+
+ await mockCalendarToHaveNoBusySlots("googlecalendar", {
+ create: {
+ id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID",
+ },
+ });
+
+ const mockBookingData = getMockRequestDataForBooking({
+ data: {
+ user: organizer.username,
+ eventTypeId: 1,
+ responses: {
+ email: booker.email,
+ name: booker.name,
+ location: { optionValue: "", value: BookingLocations.CalVideo },
+ },
+ },
+ });
+
+ const createdBooking = await handleNewBooking({
+ bookingData: mockBookingData,
+ });
+
+ expectDecoyBookingResponse(createdBooking);
+ expect(createdBooking.attendees[0].email).toBe(blockedEmail);
+ await expectNoBookingInDatabase(blockedEmail);
+ },
+ timeout
+ );
+
+ test(
+ "should NOT block booking when email is in a different organization's watchlist",
+ async () => {
+ const handleNewBooking = getNewBookingHandler();
+ const blockedEmail = "different-org-user@example.com";
+
+ // Create two different organizations with teams
+ const organizerOrg = await createOrganization({
+ name: "Organizer Org",
+ slug: "organizer-org",
+ withTeam: true,
+ });
+
+ const differentOrg = await createOrganization({
+ name: "Different Org",
+ slug: "different-org",
+ withTeam: true,
+ });
+
+ const booker = getBooker({
+ email: blockedEmail,
+ name: "Booker",
+ });
+
+ const organizer = getOrganizer({
+ name: "Organizer",
+ email: "organizer@example.com",
+ id: 101,
+ schedules: [TestData.schedules.IstWorkHours],
+ credentials: [getGoogleCalendarCredential()],
+ selectedCalendars: [TestData.selectedCalendars.google],
+ organizationId: organizerOrg.id,
+ });
+
+ // Block email in a DIFFERENT organization
+ await createOrganizationWatchlistEntry(differentOrg.id, {
+ type: WatchlistType.EMAIL,
+ value: blockedEmail,
+ action: "BLOCK",
+ });
+
+ // Use the child team ID for the eventType
+ const teamId =
+ organizerOrg.children && organizerOrg.children.length > 0 ? organizerOrg.children[0].id : null;
+
+ await createBookingScenario(
+ getScenarioData(
+ {
+ eventTypes: [
+ {
+ id: 1,
+ slotInterval: 30,
+ length: 30,
+ teamId,
+ users: [
+ {
+ id: 101,
+ },
+ ],
+ },
+ ],
+ organizer,
+ apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
+ },
+ { id: organizerOrg.id }
+ )
+ );
+
+ await mockCalendarToHaveNoBusySlots("googlecalendar", {
+ create: {
+ id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID",
+ },
+ });
+
+ const mockBookingData = getMockRequestDataForBooking({
+ data: {
+ user: organizer.username,
+ eventTypeId: 1,
+ responses: {
+ email: booker.email,
+ name: booker.name,
+ location: { optionValue: "", value: BookingLocations.CalVideo },
+ },
+ },
+ });
+
+ const createdBooking = await handleNewBooking({
+ bookingData: mockBookingData,
+ });
+
+ // Should NOT be a decoy response - booking should succeed
+ expect(createdBooking).not.toHaveProperty("isShortCircuitedBooking");
+ expect(createdBooking.status).toBe(BookingStatus.ACCEPTED);
+ expect(createdBooking.id).not.toBe(0);
+ expect(createdBooking.attendees[0].email).toBe(blockedEmail);
+ },
+ timeout
+ );
+
+ test(
+ "should block booking for managed event when email is in organization watchlist",
+ async () => {
+ const handleNewBooking = getNewBookingHandler();
+ const blockedEmail = "managed-event-spammer@example.com";
+
+ // Create organization with a team
+ const org = await createOrganization({
+ name: "Managed Event Org",
+ slug: "managed-event-org",
+ withTeam: true,
+ });
+
+ const booker = getBooker({
+ email: blockedEmail,
+ name: "Managed Event Booker",
+ });
+
+ const organizer = getOrganizer({
+ name: "Organizer",
+ email: "organizer@example.com",
+ id: 101,
+ schedules: [TestData.schedules.IstWorkHours],
+ credentials: [getGoogleCalendarCredential()],
+ selectedCalendars: [TestData.selectedCalendars.google],
+ organizationId: org.id,
+ });
+
+ await createOrganizationWatchlistEntry(org.id, {
+ type: WatchlistType.EMAIL,
+ value: blockedEmail,
+ action: "BLOCK",
+ });
+
+ // Get the child team ID
+ const teamId = org.children && org.children.length > 0 ? org.children[0].id : null;
+
+ // Create a parent event type and a managed (child) event type
+ await createBookingScenario(
+ getScenarioData(
+ {
+ eventTypes: [
+ // Parent event type
+ {
+ id: 1,
+ slotInterval: 30,
+ length: 30,
+ teamId,
+ users: [
+ {
+ id: 101,
+ },
+ ],
+ },
+ // Managed event type (child) - has parent but no teamId
+ {
+ id: 2,
+ slotInterval: 30,
+ length: 30,
+ parent: { id: 1 }, // References parent event type
+ users: [
+ {
+ id: 101,
+ },
+ ],
+ },
+ ],
+ organizer,
+ apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
+ },
+ { id: org.id }
+ )
+ );
+
+ await mockCalendarToHaveNoBusySlots("googlecalendar", {
+ create: {
+ id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID",
+ },
+ });
+
+ // Try to book the managed event (id: 2)
+ const mockBookingData = getMockRequestDataForBooking({
+ data: {
+ user: organizer.username,
+ eventTypeId: 2, // Booking the managed event
+ responses: {
+ email: booker.email,
+ name: booker.name,
+ location: { optionValue: "", value: BookingLocations.CalVideo },
+ },
+ },
+ });
+
+ const createdBooking = await handleNewBooking({
+ bookingData: mockBookingData,
+ });
+
+ // Should return a decoy response since email is blocked in the organization
+ expectDecoyBookingResponse(createdBooking);
+ expect(createdBooking.attendees[0].email).toBe(blockedEmail);
+ await expectNoBookingInDatabase(blockedEmail);
+ },
+ timeout
+ );
+ });
+});
diff --git a/packages/features/di/watchlist/containers/SpamCheckService.container.ts b/packages/features/di/watchlist/containers/SpamCheckService.container.ts
new file mode 100644
index 00000000000000..72664598d96640
--- /dev/null
+++ b/packages/features/di/watchlist/containers/SpamCheckService.container.ts
@@ -0,0 +1,11 @@
+import type { GlobalBlockingService } from "@calcom/features/watchlist/lib/service/GlobalBlockingService";
+import type { OrganizationBlockingService } from "@calcom/features/watchlist/lib/service/OrganizationBlockingService";
+import { SpamCheckService } from "@calcom/features/watchlist/lib/service/SpamCheckService";
+
+import { getGlobalBlockingService, getOrganizationBlockingService } from "./watchlist";
+
+export const getSpamCheckService = (): SpamCheckService => {
+ const globalBlockingService = getGlobalBlockingService() as GlobalBlockingService;
+ const organizationBlockingService = getOrganizationBlockingService() as OrganizationBlockingService;
+ return new SpamCheckService(globalBlockingService, organizationBlockingService);
+};
diff --git a/packages/features/di/watchlist/modules/Watchlist.module.ts b/packages/features/di/watchlist/modules/Watchlist.module.ts
index 983355d386ed2a..243c01eabba81b 100644
--- a/packages/features/di/watchlist/modules/Watchlist.module.ts
+++ b/packages/features/di/watchlist/modules/Watchlist.module.ts
@@ -6,9 +6,19 @@ import { OrganizationWatchlistRepository } from "@calcom/features/watchlist/lib/
import { PrismaWatchlistAuditRepository } from "@calcom/features/watchlist/lib/repository/PrismaWatchlistAuditRepository";
import { WATCHLIST_DI_TOKENS } from "../Watchlist.tokens";
+import { GlobalBlockingService } from "@calcom/features/watchlist/lib/service/GlobalBlockingService";
+import { OrganizationBlockingService } from "@calcom/features/watchlist/lib/service/OrganizationBlockingService";
export const watchlistModule = createModule();
+watchlistModule.bind(WATCHLIST_DI_TOKENS.GLOBAL_BLOCKING_SERVICE).toClass(GlobalBlockingService, {
+ globalRepo: DI_TOKENS.GLOBAL_WATCHLIST_REPOSITORY,
+});
+
+watchlistModule.bind(WATCHLIST_DI_TOKENS.ORGANIZATION_BLOCKING_SERVICE).toClass(OrganizationBlockingService,{
+ orgRepo: DI_TOKENS.ORGANIZATION_WATCHLIST_REPOSITORY,
+});
+
// Bind specialized repositories
watchlistModule
.bind(WATCHLIST_DI_TOKENS.GLOBAL_WATCHLIST_REPOSITORY)
diff --git a/packages/features/watchlist/lib/service/SpamCheckService.ts b/packages/features/watchlist/lib/service/SpamCheckService.ts
new file mode 100644
index 00000000000000..7b2d6a0bf390da
--- /dev/null
+++ b/packages/features/watchlist/lib/service/SpamCheckService.ts
@@ -0,0 +1,63 @@
+import logger from "@calcom/lib/logger";
+import { safeStringify } from "@calcom/lib/safeStringify";
+
+import type { BlockingResult } from "../interface/IBlockingService";
+import type { GlobalBlockingService } from "./GlobalBlockingService";
+import type { OrganizationBlockingService } from "./OrganizationBlockingService";
+
+/**
+ * Spam Check Service - Orchestrates spam checking by coordinating blocking checks
+ *
+ * Checks both global watchlist entries (via GlobalBlockingService) and organization-specific
+ * watchlist entries (via OrganizationBlockingService) when an organizationId is provided.
+ */
+export class SpamCheckService {
+ private spamCheckPromise: Promise | null = null;
+
+ constructor(
+ private readonly globalBlockingService: GlobalBlockingService,
+ private readonly organizationBlockingService: OrganizationBlockingService
+ ) { }
+
+ startCheck({ email, organizationId }: { email: string, organizationId: number | null }): void {
+ this.spamCheckPromise = this.isBlocked(email, organizationId ?? undefined).catch((error) => {
+ logger.error("Error starting spam check", safeStringify(error));
+ return { isBlocked: false };
+ });
+ }
+
+ async waitForCheck(): Promise {
+ if (!this.spamCheckPromise) {
+ throw new Error(
+ "waitForCheck() called before startCheck(). You must call startCheck() first to initialize spam checking."
+ );
+ }
+ return await this.spamCheckPromise;
+ }
+
+ /**
+ * Checks if an email is blocked by global or organization-specific watchlist rules
+ * Runs both checks in parallel for better performance
+ */
+ private async isBlocked(email: string, organizationId?: number): Promise {
+ const checks = [this.globalBlockingService.isBlocked(email)];
+
+ if (organizationId) {
+ checks.push(this.organizationBlockingService.isBlocked(email, organizationId));
+ }
+
+ const [globalResult, orgResult] = await Promise.all(checks);
+
+ // Global blocking takes precedence
+ if (globalResult.isBlocked) {
+ return globalResult;
+ }
+
+ // Check organization blocking if it was performed
+ if (orgResult?.isBlocked) {
+ return orgResult;
+ }
+
+ return { isBlocked: false };
+ }
+}