diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 7a33982a92..2a9a6d4144 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -1,6 +1,6 @@ import short, { uuid } from "short-uuid"; import { v5 as uuidv5 } from "uuid"; - +import { ProfileRepository } from "@calcom/features/profile/repositories/ProfileRepository"; import processExternalId from "@calcom/app-store/_utils/calendars/processExternalId"; import { getPaymentAppData } from "@calcom/app-store/_utils/payments/getPaymentAppData"; import { @@ -23,6 +23,7 @@ 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, @@ -35,12 +36,15 @@ import { handleWebhookTrigger } from "@calcom/features/bookings/lib/handleWebhoo import { isEventTypeLoggingEnabled } from "@calcom/features/bookings/lib/isEventTypeLoggingEnabled"; import type { CacheService } from "@calcom/features/calendar-cache/lib/getShouldServeCache"; import { getSpamCheckService } from "@calcom/features/di/watchlist/containers/SpamCheckService.container"; +import { getBookerBaseUrl } from "@calcom/features/ee/organizations/lib/getBookerUrlServer"; 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"; @@ -56,21 +60,16 @@ import { DEFAULT_GROUP_ID } from "@calcom/lib/constants"; import { ErrorCode } from "@calcom/lib/errorCodes"; import { getErrorFromUnknown } from "@calcom/lib/errors"; import { extractBaseEmail } from "@calcom/lib/extract-base-email"; -import { getBookerBaseUrl } from "@calcom/features/ee/organizations/lib/getBookerUrlServer"; 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"; @@ -96,6 +95,7 @@ import type { CredentialForCalendarService } from "@calcom/types/Credential"; import type { EventResult, PartialReference } from "@calcom/types/EventManager"; import type { EventPayloadType, EventTypeInfo } from "../../webhooks/lib/sendPayload"; +import type { BookingRepository } from "../repositories/BookingRepository"; import { BookingActionMap, BookingEmailSmsHandler } from "./BookingEmailSmsHandler"; import { getAllCredentialsIncludeServiceAccountKey } from "./getAllCredentialsForUsersOnEvent/getAllCredentials"; import { refreshCredentials } from "./getAllCredentialsForUsersOnEvent/refreshCredentials"; @@ -427,6 +427,45 @@ export interface IBookingServiceDependencies { attributeRepository: AttributeRepository; } +/** + * TODO: Ideally we should send organizationId directly to handleNewBooking. + * webapp can derive from domain and API V2 knows it already through its endpoint URL + */ +async function getEventOrganizationId({ + eventType, +}: { + eventType: { + userId: number | null; + team: { + parentId: number | null; + } | null; + parent: { + team: { + parentId: number | null; + } | null; + } | null; + }; +}) { + let eventOrganizationId: number | null = null; + const team = eventType.team ?? eventType.parent?.team ?? null; + eventOrganizationId = team?.parentId ?? null; + + if (eventOrganizationId) { + return eventOrganizationId; + } + + if (eventType.userId) { + // TODO: Moving it to instance based access through DI in a followup + const profile = await ProfileRepository.findFirstForUserId({ + userId: eventType.userId, + }); + eventOrganizationId = profile?.organizationId ?? null; + return eventOrganizationId; + } + + return eventOrganizationId; +} + async function handler( input: BookingHandlerInput, deps: IBookingServiceDependencies, @@ -509,9 +548,10 @@ 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; + const eventOrganizationId = await getEventOrganizationId({ + eventType, + }); + spamCheckService.startCheck({ email: bookerEmail, organizationId: eventOrganizationId }); if (!rawBookingData.rescheduleUid) { @@ -1492,7 +1532,7 @@ async function handler( 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 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 index 9f33cf0f08..94787b2d93 100644 --- a/packages/features/bookings/lib/handleNewBooking/test/spam-booking.integration-test.ts +++ b/packages/features/bookings/lib/handleNewBooking/test/spam-booking.integration-test.ts @@ -10,12 +10,12 @@ import { 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 { prisma } from "@calcom/prisma"; import { WatchlistType, BookingStatus } from "@calcom/prisma/enums"; import { test } from "@calcom/web/test/fixtures/fixtures"; @@ -785,5 +785,89 @@ describe("handleNewBooking - Spam Detection", () => { }, timeout ); + + test( + "should block booking for user event in organization when email is in organization watchlist", + async () => { + const handleNewBooking = getNewBookingHandler(); + const blockedEmail = "user-event-spammer@example.com"; + + // Create organization + const org = await createOrganization({ + name: "User Event Org", + slug: "user-event-org", + withTeam: false, + }); + + const booker = getBooker({ + email: blockedEmail, + name: "User 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", + }); + + // Create a user event (no teamId) but with a profile linking to the organization + await createBookingScenario( + getScenarioData( + { + eventTypes: [ + { + id: 1, + slotInterval: 30, + length: 30, + // User Event Type has userId set + userId: 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, + }); + + // 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/ee/organizations/lib/orgDomains.ts b/packages/features/ee/organizations/lib/orgDomains.ts index 8bbb925be9..483c120db6 100644 --- a/packages/features/ee/organizations/lib/orgDomains.ts +++ b/packages/features/ee/organizations/lib/orgDomains.ts @@ -15,8 +15,8 @@ const log = logger.getSubLogger({ */ export function getOrgSlug(hostname: string, forcedSlug?: string) { if (forcedSlug) { - if (process.env.NEXT_PUBLIC_IS_E2E) { - log.debug("Using provided forcedSlug in E2E", { + if (process.env.NEXT_PUBLIC_IS_E2E || process.env.INTEGRATION_TEST_MODE) { + log.debug("Using provided forcedSlug in E2E/Integration Test mode", { forcedSlug, }); return forcedSlug; diff --git a/packages/features/profile/repositories/ProfileRepository.ts b/packages/features/profile/repositories/ProfileRepository.ts index 9d12052399..f7193091be 100644 --- a/packages/features/profile/repositories/ProfileRepository.ts +++ b/packages/features/profile/repositories/ProfileRepository.ts @@ -58,6 +58,17 @@ const organizationWithSettingsSelect = { }, }; +const profileSelect = { + id: true, + uid: true, + userId: true, + organizationId: true, + username: true, + createdAt: true, + updatedAt: true, +}; + + export enum LookupTarget { User, Profile, @@ -627,6 +638,15 @@ export class ProfileRepository { return profiles; } + static async findFirstForUserId({ userId }: { userId: number }) { + return prisma.profile.findFirst({ + where: { + userId: userId, + }, + select: profileSelect, + }); + } + static async findManyForOrg({ organizationId }: { organizationId: number }) { return await prisma.profile.findMany({ where: {