From 6aeef0390db9698ea2d42660ce5d0a227b6fa9a9 Mon Sep 17 00:00:00 2001 From: Hariom Balhara Date: Wed, 24 Sep 2025 11:31:56 +0530 Subject: [PATCH 1/5] chore: Move post booking stuff to separate service --- .../d/[link]/[slug]/getServerSideProps.tsx | 7 +- .../getMockRequestDataForBooking.ts | 4 +- .../di/RegularBookingService.module.ts | 2 + packages/features/bookings/lib/dto/types.d.ts | 5 + .../test/post-booking-handling.test.ts | 473 ++++++++++++++++++ .../BookingEventHandlerService.ts | 70 +++ .../bookings/lib/onBookingEvents/types.d.ts | 39 ++ .../lib/service/RegularBookingService.ts | 98 ++-- .../features/di/containers/AvailableSlots.ts | 3 +- .../di/containers/NoSlotsNotification.ts | 2 +- packages/features/di/modules/Membership.ts | 9 - packages/features/di/tokens.ts | 5 + .../lib/allowDisablingStandardEmails.ts | 11 +- .../di/HashedLinkRepository.module.ts | 23 + .../hashedLink/di/HashedLinkService.module.ts | 27 + packages/features/hashedLink/di/tokens.ts | 6 + .../repository/HashedLinkRepository.ts} | 0 .../service/HashedLinkService.ts} | 36 +- .../users/di/MembershipRepository.module.ts | 22 + .../users/di/MembershipService.module.ts | 25 + .../eventTypes/getHashedLink.handler.ts | 4 +- .../eventTypes/getHashedLinks.handler.ts | 4 +- .../viewer/eventTypes/heavy/update.handler.ts | 12 +- 23 files changed, 808 insertions(+), 79 deletions(-) create mode 100644 packages/features/bookings/lib/handleNewBooking/test/post-booking-handling.test.ts create mode 100644 packages/features/bookings/lib/onBookingEvents/BookingEventHandlerService.ts create mode 100644 packages/features/bookings/lib/onBookingEvents/types.d.ts delete mode 100644 packages/features/di/modules/Membership.ts create mode 100644 packages/features/hashedLink/di/HashedLinkRepository.module.ts create mode 100644 packages/features/hashedLink/di/HashedLinkService.module.ts create mode 100644 packages/features/hashedLink/di/tokens.ts rename packages/features/hashedLink/{repositories/hashedLinkRepository.ts => lib/repository/HashedLinkRepository.ts} (100%) rename packages/features/hashedLink/{services/hashedLinkService.ts => lib/service/HashedLinkService.ts} (85%) create mode 100644 packages/features/users/di/MembershipRepository.module.ts create mode 100644 packages/features/users/di/MembershipService.module.ts diff --git a/apps/web/lib/d/[link]/[slug]/getServerSideProps.tsx b/apps/web/lib/d/[link]/[slug]/getServerSideProps.tsx index 7ea04380bddf47..e64ca135584603 100644 --- a/apps/web/lib/d/[link]/[slug]/getServerSideProps.tsx +++ b/apps/web/lib/d/[link]/[slug]/getServerSideProps.tsx @@ -8,12 +8,12 @@ import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking"; import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains"; import { EventRepository } from "@calcom/features/eventtypes/repositories/EventRepository"; import { FeaturesRepository } from "@calcom/features/flags/features.repository"; -import { HashedLinkService } from "@calcom/features/hashedLink/services/hashedLinkService"; import { shouldHideBrandingForTeamEvent, shouldHideBrandingForUserEvent, } from "@calcom/features/profile/lib/hideBranding"; import { UserRepository } from "@calcom/features/users/repositories/UserRepository"; +import { HashedLinkService } from "@calcom/features/hashedLink/lib/service/HashedLinkService"; import slugify from "@calcom/lib/slugify"; import prisma from "@calcom/prisma"; import { RedirectType } from "@calcom/prisma/enums"; @@ -82,9 +82,8 @@ async function getUserPageProps(context: GetServerSidePropsContext) { redirect: { permanent: false, // App Router doesn't have access to the current path directly, so we build it manually - destination: `${redirectWithOriginAndSearchString.origin ?? ""}/d/${link}/${slug}${ - redirectWithOriginAndSearchString.searchString - }`, + destination: `${redirectWithOriginAndSearchString.origin ?? ""}/d/${link}/${slug}${redirectWithOriginAndSearchString.searchString + }`, }, }; } diff --git a/apps/web/test/utils/bookingScenario/getMockRequestDataForBooking.ts b/apps/web/test/utils/bookingScenario/getMockRequestDataForBooking.ts index 968b4952fcfe52..b112c06914678f 100644 --- a/apps/web/test/utils/bookingScenario/getMockRequestDataForBooking.ts +++ b/apps/web/test/utils/bookingScenario/getMockRequestDataForBooking.ts @@ -15,7 +15,6 @@ export function getBasicMockRequestDataForBooking() { user: "teampro", metadata: {}, hasHashedBookingLink: false, - hashedLink: null, }; } @@ -35,6 +34,9 @@ type CommonPropsMockRequestData = { attendeePhoneNumber?: string; smsReminderNumber?: string; }; + _isDryRun?: boolean; + hashedLink?: string; + hasHashedBookingLink?: boolean; }; export function getMockRequestDataForBooking({ diff --git a/packages/features/bookings/di/RegularBookingService.module.ts b/packages/features/bookings/di/RegularBookingService.module.ts index e338f753c63f0f..c78155e2f1afea 100644 --- a/packages/features/bookings/di/RegularBookingService.module.ts +++ b/packages/features/bookings/di/RegularBookingService.module.ts @@ -6,6 +6,7 @@ import { moduleLoader as checkBookingAndDurationLimitsModuleLoader } from "@calc import { moduleLoader as luckyUserServiceModuleLoader } from "@calcom/features/di/modules/LuckyUser"; import { moduleLoader as userRepositoryModuleLoader } from "@calcom/features/di/modules/User"; import { DI_TOKENS } from "@calcom/features/di/tokens"; +import { moduleLoader as hashedLinkServiceModuleLoader } from "@calcom/features/hashedLink/di/HashedLinkService.module"; import { moduleLoader as prismaModuleLoader } from "@calcom/prisma/prisma.module"; const thisModule = createModule(); @@ -24,6 +25,7 @@ const loadModule = bindModuleToClassOnToken({ bookingRepository: bookingRepositoryModuleLoader, luckyUserService: luckyUserServiceModuleLoader, userRepository: userRepositoryModuleLoader, + hashedLinkService: hashedLinkServiceModuleLoader, }, }); diff --git a/packages/features/bookings/lib/dto/types.d.ts b/packages/features/bookings/lib/dto/types.d.ts index 3a5f3ea91be48d..386b71e99305a5 100644 --- a/packages/features/bookings/lib/dto/types.d.ts +++ b/packages/features/bookings/lib/dto/types.d.ts @@ -54,3 +54,8 @@ export type InstantBookingCreateResult = { expires: Date; userId: number | null; }; + +// More properties to be added to this config in followup PRs +export type BookingFlowConfig = { + isDryRun: boolean; +}; diff --git a/packages/features/bookings/lib/handleNewBooking/test/post-booking-handling.test.ts b/packages/features/bookings/lib/handleNewBooking/test/post-booking-handling.test.ts new file mode 100644 index 00000000000000..f75b8f98505236 --- /dev/null +++ b/packages/features/bookings/lib/handleNewBooking/test/post-booking-handling.test.ts @@ -0,0 +1,473 @@ +/** + * Post-Booking handling tests + * + * These tests focus specifically on testing what happens after a successful booking or rescheduling. + */ +import prismaMock from "../../../../../../tests/libs/__mocks__/prisma"; + +import { + createBookingScenario, + getDate, + getGoogleCalendarCredential, + TestData, + getOrganizer, + getBooker, + getScenarioData, + mockCalendarToHaveNoBusySlots, + BookingLocations, +} from "@calcom/web/test/utils/bookingScenario/bookingScenario"; +import { expectBookingToBeInDatabase } from "@calcom/web/test/utils/bookingScenario/expects"; +import { getMockRequestDataForBooking } from "@calcom/web/test/utils/bookingScenario/getMockRequestDataForBooking"; +import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown"; + +import type { Request, Response } from "express"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { describe, expect, beforeEach, vi, test } from "vitest"; + +import { resetTestEmails } from "@calcom/lib/testEmails"; +import { BookingStatus } from "@calcom/prisma/enums"; + +import { getNewBookingHandler } from "./getNewBookingHandler"; + +export type CustomNextApiRequest = NextApiRequest & Request; +export type CustomNextApiResponse = NextApiResponse & Response; + +// Local test runs sometimes get too slow +const timeout = process.env.CI ? 5000 : 20000; + +// Helper function to create hashed link test data +function createHashedLinkTestData({ + eventTypeId, + usageLimit = 5, + currentUsage = 0, +}: { + eventTypeId: number; + usageLimit?: number; + currentUsage?: number; +}) { + const link = "test-hashed-link-abc123"; + return { + id: 1, + link, + eventTypeId, + maxUsageCount: usageLimit, + usageCount: currentUsage, + expiresAt: null, + }; +} + +// Helper function to verify hashed link usage count +async function expectHashedLinkUsageToBe(linkId: string, expectedUsageCount: number) { + const hashedLink = await prismaMock.hashedLink.findUnique({ + where: { link: linkId }, + }); + expect(hashedLink?.usageCount).toBe(expectedUsageCount); +} + +describe("Post-Booking Events - Hashed Link Usage", () => { + setupAndTeardown(); + beforeEach(() => { + // Reset test state before each test + resetTestEmails(); + vi.clearAllMocks(); + }); + + describe("BookingEventHandler Hashed Link Usage Tracking", () => { + test( + `should increment hashed link usage when booking is created with hashed link + 1. Should create a booking in the database + 2. Should increment hashed link usage count via BookingEventHandler.onBookingCreated + `, + async () => { + const handleNewBooking = getNewBookingHandler(); + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + }); + + const hashedLinkData = createHashedLinkTestData({ + eventTypeId: 1, + usageLimit: 5, + currentUsage: 2, // Should be incremented to 3 after booking + }); + + await createBookingScenario( + getScenarioData({ + eventTypes: [ + { + id: 1, + slotInterval: 45, + length: 30, + users: [ + { + id: 101, + }, + ], + }, + ], + users: [organizer], + }) + ); + + // Create the hashed link in the database + await prismaMock.hashedLink.create({ + data: hashedLinkData, + }); + + mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { + uid: "HASHED_LINK_BOOKING_UID", + }, + update: { + iCalUID: "HASHED_LINK_BOOKING_UID", + uid: "HASHED_LINK_BOOKING_UID", + }, + }); + + const mockBookingRequest = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: BookingLocations.CalVideo }, + }, + hashedLink: hashedLinkData.link, + hasHashedBookingLink: true, + }, + }); + + const bookingResponse = await handleNewBooking({ + bookingData: mockBookingRequest, + }); + + await expectBookingToBeInDatabase({ + description: "", + uid: bookingResponse.uid, + location: BookingLocations.CalVideo, + responses: expect.objectContaining({ + email: booker.email, + name: booker.name, + }), + status: BookingStatus.ACCEPTED, + }); + + await expectHashedLinkUsageToBe(hashedLinkData.link, hashedLinkData.usageCount + 1); + + expect(bookingResponse.status).toEqual(BookingStatus.ACCEPTED); + expect(bookingResponse.uid).toBeDefined(); + }, + timeout + ); + + test( + `should increment hashed link usage when rescheduling a booking with hashed link + 1. Should reschedule the booking in the database + 2. Should increment hashed link usage count via BookingEventHandler.onBookingRescheduled + `, + async () => { + const handleNewBooking = getNewBookingHandler(); + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + }); + + const hashedLinkData = createHashedLinkTestData({ + eventTypeId: 1, + usageLimit: 5, + currentUsage: 2, + }); + + // Create an existing booking to reschedule + const existingBookingUid = "existing-booking-uid"; + + await createBookingScenario( + getScenarioData({ + eventTypes: [ + { + id: 1, + slotInterval: 45, + length: 30, + users: [ + { + id: 101, + }, + ], + }, + ], + users: [organizer], + bookings: [ + { + uid: existingBookingUid, + eventTypeId: 1, + userId: organizer.id, + status: BookingStatus.ACCEPTED, + startTime: `${getDate({ dateIncrement: 1 }).dateString}T05:00:00.000Z`, + endTime: `${getDate({ dateIncrement: 1 }).dateString}T05:45:00.000Z`, + }, + ], + }) + ); + + // Create the hashed link in the database + await prismaMock.hashedLink.create({ + data: hashedLinkData, + }); + + mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { + uid: "RESCHEDULED_BOOKING_UID", + }, + update: { + iCalUID: "RESCHEDULED_BOOKING_UID", + uid: "RESCHEDULED_BOOKING_UID", + }, + }); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: BookingLocations.CalVideo }, + }, + hashedLink: hashedLinkData.link, + hasHashedBookingLink: true, + rescheduleUid: existingBookingUid, + }, + }); + + const bookingResponse = await handleNewBooking({ + bookingData: mockBookingData, + }); + + if (bookingResponse.uid) { + await expectBookingToBeInDatabase({ + description: "", + location: BookingLocations.CalVideo, + responses: expect.objectContaining({ + email: booker.email, + name: booker.name, + }), + status: BookingStatus.ACCEPTED, + uid: bookingResponse.uid, + }); + } + + await expectHashedLinkUsageToBe(hashedLinkData.link, hashedLinkData.usageCount + 1); + + expect(bookingResponse.status).toEqual(BookingStatus.ACCEPTED); + expect(bookingResponse.uid).toBeDefined(); + }, + timeout + ); + + test( + `should not increment hashed link usage during dry run + 1. Should not increment hashed link usage (BookingEventHandler should skip processing) + `, + async () => { + const handleNewBooking = getNewBookingHandler(); + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + }); + + const hashedLinkData = createHashedLinkTestData({ + eventTypeId: 1, + usageLimit: 5, + currentUsage: 2, + }); + + await createBookingScenario( + getScenarioData({ + eventTypes: [ + { + id: 1, + slotInterval: 45, + length: 30, + users: [ + { + id: 101, + }, + ], + // hashedLink will be created separately in database + }, + ], + users: [organizer], + }) + ); + + mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { + uid: "DRY_RUN_BOOKING_UID", + }, + }); + + // Create the hashed link in the database + await prismaMock.hashedLink.create({ + data: hashedLinkData, + }); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: BookingLocations.CalVideo }, + }, + hashedLink: hashedLinkData.link, + hasHashedBookingLink: true, + _isDryRun: true, + }, + }); + + const bookingResponse = await handleNewBooking({ + bookingData: mockBookingData, + }); + + expect(bookingResponse.uid).toBe("DRY_RUN_UID"); + // Verify hashed link usage was NOT incremented during dry run + await expectHashedLinkUsageToBe(hashedLinkData.link, hashedLinkData.usageCount); + + expect(bookingResponse.status).toEqual(BookingStatus.ACCEPTED); + }, + timeout + ); + + test( + `should handle hashed link service errors gracefully without failing booking creation + 1. Should create a booking successfully + 2. Should handle hashed link service errors gracefully in BookingEventHandler + 3. Should log errors but continue booking flow + `, + async () => { + const handleNewBooking = getNewBookingHandler(); + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + }); + + const hashedLinkData = createHashedLinkTestData({ + eventTypeId: 1, + usageLimit: 5, + currentUsage: 2, + }); + + await createBookingScenario( + getScenarioData({ + eventTypes: [ + { + id: 1, + slotInterval: 45, + length: 30, + users: [ + { + id: 101, + }, + ], + // hashedLink will be created separately in database + }, + ], + users: [organizer], + }) + ); + + mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { + uid: "ERROR_HANDLING_BOOKING_UID", + }, + update: { + iCalUID: "ERROR_HANDLING_BOOKING_UID", + uid: "ERROR_HANDLING_BOOKING_UID", + }, + }); + + // Create an invalid hashed link that will cause validation to fail + // (maxUsageCount = 0 means it should be expired) + const invalidHashedLinkData = { + ...hashedLinkData, + maxUsageCount: 0, // This will cause validation to fail + }; + + await prismaMock.hashedLink.create({ + data: invalidHashedLinkData, + }); + + const mockBookingRequest = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: BookingLocations.CalVideo }, + }, + hasHashedBookingLink: true, + hashedLink: hashedLinkData.link, + }, + }); + + const bookingResponse = await handleNewBooking({ + bookingData: mockBookingRequest, + }); + + // Booking should still be created successfully despite hashed link error + await expectBookingToBeInDatabase({ + description: "", + uid: bookingResponse.uid, + location: BookingLocations.CalVideo, + responses: expect.objectContaining({ + email: booker.email, + name: booker.name, + }), + status: BookingStatus.ACCEPTED, + }); + + // Verify that even though hashed link processing failed, + await expectHashedLinkUsageToBe(hashedLinkData.link, invalidHashedLinkData.usageCount); + + expect(bookingResponse.status).toEqual(BookingStatus.ACCEPTED); + expect(bookingResponse.uid).toBeDefined(); + }, + timeout + ); + }); +}); diff --git a/packages/features/bookings/lib/onBookingEvents/BookingEventHandlerService.ts b/packages/features/bookings/lib/onBookingEvents/BookingEventHandlerService.ts new file mode 100644 index 00000000000000..6fb3b5acf6da94 --- /dev/null +++ b/packages/features/bookings/lib/onBookingEvents/BookingEventHandlerService.ts @@ -0,0 +1,70 @@ +import type { Logger } from "tslog"; + +import type { HashedLinkService } from "@calcom/features/hashedLink/lib/service/HashedLinkService"; +import { safeStringify } from "@calcom/lib/safeStringify"; + +import type { BookingCreatedPayload, BookingRescheduledPayload } from "./types"; + +interface BookingEventHandlerDeps { + log: Logger; + hashedLinkService: HashedLinkService; +} + +export class BookingEventHandlerService { + private readonly log: BookingEventHandlerDeps["log"]; + private readonly hashedLinkService: BookingEventHandlerDeps["hashedLinkService"]; + + constructor(private readonly deps: BookingEventHandlerDeps) { + this.log = deps.log; + this.hashedLinkService = deps.hashedLinkService; + } + + async onBookingCreated(payload: BookingCreatedPayload) { + this.log.debug("onBookingCreated", safeStringify(payload)); + if (!this.shouldProcess(payload)) { + return; + } + await this.onBookingCreatedOrRescheduled(payload); + } + + async onBookingRescheduled(payload: BookingRescheduledPayload) { + this.log.debug("onBookingRescheduled", safeStringify(payload)); + if (!this.shouldProcess(payload)) { + return; + } + await this.onBookingCreatedOrRescheduled(payload); + } + + /** + * Handles common tasks that need to be executed in both booking created and rescheduled events + * A dedicated place because there are many tasks that need to be executed in both events. + */ + private async onBookingCreatedOrRescheduled(payload: BookingCreatedPayload | BookingRescheduledPayload) { + const results = await Promise.allSettled([ + // TODO: Migrate other post-booking tasks here, to execute them in parallel, without affecting each other + this.updatePrivateLinkUsage(payload), + ]); + results.forEach((result) => { + if (result.status === "rejected") { + this.log.error( + "Error while executing onBookingCreatedOrRescheduled task", + safeStringify(result.reason) + ); + } + }); + } + + private async updatePrivateLinkUsage(payload: BookingCreatedPayload | BookingRescheduledPayload) { + try { + if (payload.bookingFormData.hashedLink) { + await this.deps.hashedLinkService.validateAndIncrementUsage(payload.bookingFormData.hashedLink); + } + } catch (error) { + this.log.error("Error while updating hashed link", safeStringify(error)); + } + } + + private shouldProcess(payload: BookingCreatedPayload | BookingRescheduledPayload) { + return !payload.config.isDryRun; + } +} diff --git a/packages/features/bookings/lib/onBookingEvents/types.d.ts b/packages/features/bookings/lib/onBookingEvents/types.d.ts new file mode 100644 index 00000000000000..a0a306a4c183ae --- /dev/null +++ b/packages/features/bookings/lib/onBookingEvents/types.d.ts @@ -0,0 +1,39 @@ +import type { BookingStatus, SchedulingType } from "@calcom/prisma/enums"; + +import type { BookingFlowConfig } from "./dto/types"; + +type BookingEventType = { + id: number; + slug: string; + schedulingType: SchedulingType | null; + metadata: Record | null; + hashedLink?: string; +}; + +type CreatedBooking = { + uid: string; + userId: number | null; + status: BookingStatus; + startTime: Date; + endTime: Date; + location: string | null; +}; + +export interface BookingCreatedPayload { + booking: CreatedBooking; + eventType: BookingEventType; + config: BookingFlowConfig; + bookingFormData: { + hashedLink: string | null; + }; +} + +export interface BookingRescheduledPayload extends BookingCreatedPayload { + reschedule: { + originalBooking: { + uid: string; + }; + rescheduleReason: string | null; + rescheduledBy: string | null; + }; +} diff --git a/packages/features/bookings/lib/service/RegularBookingService.ts b/packages/features/bookings/lib/service/RegularBookingService.ts index ac071a656397ae..8d3648ec403f99 100644 --- a/packages/features/bookings/lib/service/RegularBookingService.ts +++ b/packages/features/bookings/lib/service/RegularBookingService.ts @@ -33,17 +33,17 @@ 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 { BookingEventHandlerService } from "@calcom/features/bookings/lib/onBookingEvents/BookingEventHandlerService"; 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 { WorkflowService } from "@calcom/features/ee/workflows/lib/service/WorkflowService"; 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 { getFullName } from "@calcom/features/form-builder/utils"; -import { HashedLinkService } from "@calcom/features/hashedLink/services/hashedLinkService"; import { ProfileRepository } from "@calcom/features/profile/repositories/ProfileRepository"; +import type { HashedLinkService } from "@calcom/features/hashedLink/lib/service/HashedLinkService"; 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"; @@ -69,6 +69,7 @@ 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 { WorkflowService } from "@calcom/features/ee/workflows/lib/service/WorkflowService"; import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat"; import type { PrismaClient } from "@calcom/prisma"; import type { DestinationCalendar, Prisma, User, AssignmentReasonEnum } from "@calcom/prisma/client"; @@ -416,6 +417,7 @@ export interface IBookingServiceDependencies { bookingRepository: BookingRepository; luckyUserService: LuckyUserService; userRepository: UserRepository; + hashedLinkService: HashedLinkService; } /** @@ -1254,9 +1256,9 @@ async function handler( // This ensures that createMeeting isn't called for static video apps as bookingLocation becomes just a regular value for them. const { bookingLocation, conferenceCredentialId } = organizerOrFirstDynamicGroupMemberDefaultLocationUrl ? { - bookingLocation: organizerOrFirstDynamicGroupMemberDefaultLocationUrl, - conferenceCredentialId: undefined, - } + bookingLocation: organizerOrFirstDynamicGroupMemberDefaultLocationUrl, + conferenceCredentialId: undefined, + } : getLocationValueForDB(locationBodyString, eventType.locations); log.info("locationBodyString", locationBodyString); @@ -1302,8 +1304,8 @@ async function handler( const destinationCalendar = eventType.destinationCalendar ? [eventType.destinationCalendar] : organizerUser.destinationCalendar - ? [organizerUser.destinationCalendar] - : null; + ? [organizerUser.destinationCalendar] + : null; let organizerEmail = organizerUser.email || "Email-less"; if (eventType.useEventTypeDestinationCalendarEmail && destinationCalendar?.[0]?.primaryEmail) { @@ -1922,14 +1924,14 @@ async function handler( } const updateManager = !skipCalendarSyncTaskCreation ? await eventManager.reschedule( - evt, - originalRescheduledBooking.uid, - undefined, - changedOrganizer, - previousHostDestinationCalendar, - isBookingRequestedReschedule, - skipDeleteEventsAndMeetings - ) + evt, + originalRescheduledBooking.uid, + undefined, + changedOrganizer, + previousHostDestinationCalendar, + isBookingRequestedReschedule, + skipDeleteEventsAndMeetings + ) : placeholderCreatedEvent; // This gets overridden when updating the event - to check if notes have been hidden or not. We just reset this back // to the default description when we are sending the emails. @@ -2227,10 +2229,47 @@ async function handler( const metadata = videoCallUrl ? { - videoCallUrl: getVideoCallUrlFromCalEvent(evt) || videoCallUrl, - } + videoCallUrl: getVideoCallUrlFromCalEvent(evt) || videoCallUrl, + } : undefined; + const bookingFlowConfig = { + isDryRun, + }; + + const bookingCreatedPayload = { + booking, + eventType, + config: bookingFlowConfig, + bookingFormData: { + // FIXME: It looks like hasHashedBookingLink is set to true based on the value of hashedLink when sending the request. So, technically we could remove hasHashedBookingLink usage completely + hashedLink: hasHashedBookingLink ? reqBody.hashedLink ?? null : null, + }, + }; + + const bookingEventHandler = new BookingEventHandlerService({ + log: loggerWithEventDetails, + hashedLinkService: deps.hashedLinkService, + }); + + // TODO: Incrementally move all stuff that happens after a booking is created to these handlers + if (originalRescheduledBooking) { + await bookingEventHandler.onBookingRescheduled({ + ...bookingCreatedPayload, + reschedule: { + originalBooking: { + uid: originalRescheduledBooking.uid, + }, + rescheduleReason: rescheduleReason ?? null, + rescheduledBy: reqBody.rescheduledBy ?? null, + }, + }); + } else { + await bookingEventHandler.onBookingCreated({ + ...bookingCreatedPayload, + }); + } + const webhookData: EventPayloadType = { ...evt, ...eventTypeInfo, @@ -2290,9 +2329,9 @@ async function handler( ...eventType, metadata: eventType.metadata ? { - ...eventType.metadata, - apps: eventType.metadata?.apps as Prisma.JsonValue, - } + ...eventType.metadata, + apps: eventType.metadata?.apps as Prisma.JsonValue, + } : {}, }, paymentAppCredentials: eventTypePaymentAppCredential as IEventTypePaymentCredentialType, @@ -2468,23 +2507,6 @@ async function handler( }); } - try { - const hashedLinkService = new HashedLinkService(); - if (hasHashedBookingLink && reqBody.hashedLink && !isDryRun) { - await hashedLinkService.validateAndIncrementUsage(reqBody.hashedLink as string); - } - } catch (error) { - loggerWithEventDetails.error("Error while updating hashed link", JSON.stringify({ error })); - - // Handle repository errors and convert to HttpErrors - if (error instanceof Error) { - throw new HttpError({ statusCode: 410, message: error.message }); - } - - // For unexpected errors, provide a generic message - throw new HttpError({ statusCode: 500, message: "Failed to process booking link" }); - } - if (!booking) throw new HttpError({ statusCode: 400, message: "Booking failed" }); try { @@ -2606,7 +2628,7 @@ async function handler( * We are open to renaming it to something more descriptive. */ export class RegularBookingService implements IBookingService { - constructor(private readonly deps: IBookingServiceDependencies) {} + constructor(private readonly deps: IBookingServiceDependencies) { } async createBooking(input: { bookingData: CreateRegularBookingData; bookingMeta?: CreateBookingMeta }) { return handler({ bookingData: input.bookingData, ...input.bookingMeta }, this.deps); diff --git a/packages/features/di/containers/AvailableSlots.ts b/packages/features/di/containers/AvailableSlots.ts index 7ce09436e2cbea..2ddd55c9960213 100644 --- a/packages/features/di/containers/AvailableSlots.ts +++ b/packages/features/di/containers/AvailableSlots.ts @@ -2,7 +2,7 @@ import { DI_TOKENS } from "@calcom/features/di/tokens"; import { redisModule } from "@calcom/features/redis/di/redisModule"; import { prismaModule } from "@calcom/prisma/prisma.module"; import type { AvailableSlotsService } from "@calcom/trpc/server/routers/viewer/slots/util"; - +import { membershipRepositoryModule } from "@calcom/features/users/di/MembershipRepository.module"; import { createContainer } from "../di"; import { availableSlotsModule } from "../modules/AvailableSlots"; import { bookingRepositoryModule } from "../modules/Booking"; @@ -13,7 +13,6 @@ import { eventTypeRepositoryModule } from "../modules/EventType"; import { featuresRepositoryModule } from "../modules/Features"; import { filterHostsModule } from "../modules/FilterHosts"; import { getUserAvailabilityModule } from "../modules/GetUserAvailability"; -import { membershipRepositoryModule } from "../modules/Membership"; import { noSlotsNotificationModule } from "../modules/NoSlotsNotification"; import { oooRepositoryModule } from "../modules/Ooo"; import { qualifiedHostsModule } from "../modules/QualifiedHosts"; diff --git a/packages/features/di/containers/NoSlotsNotification.ts b/packages/features/di/containers/NoSlotsNotification.ts index 8ffac5ace466b7..534bc08bb01061 100644 --- a/packages/features/di/containers/NoSlotsNotification.ts +++ b/packages/features/di/containers/NoSlotsNotification.ts @@ -1,10 +1,10 @@ import { DI_TOKENS } from "@calcom/features/di/tokens"; import { redisModule } from "@calcom/features/redis/di/redisModule"; +import { membershipRepositoryModule } from "@calcom/features/users/di/MembershipRepository.module"; import { prismaModule } from "@calcom/prisma/prisma.module"; import type { NoSlotsNotificationService } from "@calcom/trpc/server/routers/viewer/slots/handleNotificationWhenNoSlots"; import { createContainer } from "../di"; -import { membershipRepositoryModule } from "../modules/Membership"; import { noSlotsNotificationModule } from "../modules/NoSlotsNotification"; import { teamRepositoryModule } from "../modules/Team"; diff --git a/packages/features/di/modules/Membership.ts b/packages/features/di/modules/Membership.ts deleted file mode 100644 index c63f8f465e14d1..00000000000000 --- a/packages/features/di/modules/Membership.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { DI_TOKENS } from "@calcom/features/di/tokens"; -import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository"; - -import { createModule } from "../di"; - -export const membershipRepositoryModule = createModule(); -membershipRepositoryModule - .bind(DI_TOKENS.MEMBERSHIP_REPOSITORY) - .toClass(MembershipRepository, [DI_TOKENS.PRISMA_CLIENT]); diff --git a/packages/features/di/tokens.ts b/packages/features/di/tokens.ts index c0809b94bf6979..1e56074e12a35a 100644 --- a/packages/features/di/tokens.ts +++ b/packages/features/di/tokens.ts @@ -1,4 +1,5 @@ import { BOOKING_DI_TOKENS } from "@calcom/features/bookings/di/tokens"; +import { HASHED_LINK_DI_TOKENS } from "@calcom/features/hashedLink/di/tokens"; import { WATCHLIST_DI_TOKENS } from "./watchlist/Watchlist.tokens"; @@ -55,7 +56,11 @@ export const DI_TOKENS = { HOST_REPOSITORY_MODULE: Symbol("HostRepositoryModule"), ATTRIBUTE_REPOSITORY: Symbol("AttributeRepository"), ATTRIBUTE_REPOSITORY_MODULE: Symbol("AttributeRepositoryModule"), + MEMBERSHIP_SERVICE: Symbol("MembershipService"), + MEMBERSHIP_SERVICE_MODULE: Symbol("MembershipServiceModule"), + // Booking service tokens ...BOOKING_DI_TOKENS, + ...HASHED_LINK_DI_TOKENS, // Watchlist service tokens ...WATCHLIST_DI_TOKENS, }; diff --git a/packages/features/ee/workflows/lib/allowDisablingStandardEmails.ts b/packages/features/ee/workflows/lib/allowDisablingStandardEmails.ts index ec301e55d6ee87..1528dddfe7cd09 100644 --- a/packages/features/ee/workflows/lib/allowDisablingStandardEmails.ts +++ b/packages/features/ee/workflows/lib/allowDisablingStandardEmails.ts @@ -1,8 +1,13 @@ import { WorkflowActions, WorkflowTriggerEvents } from "@calcom/prisma/enums"; -import type { Workflow } from "./types"; +type WorkflowWithStepsAndTrigger = { + trigger: WorkflowTriggerEvents; + steps: { + action: WorkflowActions; + }[]; +}; -export function allowDisablingHostConfirmationEmails(workflows: Workflow[]) { +export function allowDisablingHostConfirmationEmails(workflows: WorkflowWithStepsAndTrigger[]) { return !!workflows.find( (workflow) => workflow.trigger === WorkflowTriggerEvents.NEW_EVENT && @@ -10,7 +15,7 @@ export function allowDisablingHostConfirmationEmails(workflows: Workflow[]) { ); } -export function allowDisablingAttendeeConfirmationEmails(workflows: Workflow[]) { +export function allowDisablingAttendeeConfirmationEmails(workflows: WorkflowWithStepsAndTrigger[]) { return !!workflows.find( (workflow) => workflow.trigger === WorkflowTriggerEvents.NEW_EVENT && diff --git a/packages/features/hashedLink/di/HashedLinkRepository.module.ts b/packages/features/hashedLink/di/HashedLinkRepository.module.ts new file mode 100644 index 00000000000000..d31f17c4e2ddbe --- /dev/null +++ b/packages/features/hashedLink/di/HashedLinkRepository.module.ts @@ -0,0 +1,23 @@ +import { bindModuleToClassOnToken, createModule, type ModuleLoader } from "@calcom/features/di/di"; +import { DI_TOKENS } from "@calcom/features/di/tokens"; +import { moduleLoader as prismaModuleLoader } from "@calcom/prisma/prisma.module"; + +import { HashedLinkRepository } from "../lib/repository/HashedLinkRepository"; + +const thisModule = createModule(); +const token = DI_TOKENS.HASHED_LINK_REPOSITORY; +const moduleToken = DI_TOKENS.HASHED_LINK_REPOSITORY_MODULE; +const loadModule = bindModuleToClassOnToken({ + module: thisModule, + moduleToken, + token, + classs: HashedLinkRepository, + dep: prismaModuleLoader, +}); + +export const moduleLoader: ModuleLoader = { + token, + loadModule, +}; + +export type { HashedLinkRepository }; diff --git a/packages/features/hashedLink/di/HashedLinkService.module.ts b/packages/features/hashedLink/di/HashedLinkService.module.ts new file mode 100644 index 00000000000000..1462fc33a536c6 --- /dev/null +++ b/packages/features/hashedLink/di/HashedLinkService.module.ts @@ -0,0 +1,27 @@ +import { moduleLoader as membershipServiceModuleLoader } from "@calcom/features/users/di/MembershipService.module"; +import { bindModuleToClassOnToken, createModule } from "@calcom/features/di/di"; +import { DI_TOKENS } from "@calcom/features/di/tokens"; + +import { HashedLinkService } from "../lib/service/HashedLinkService"; +import { moduleLoader as hashedLinkRepositoryModuleLoader } from "./HashedLinkRepository.module"; + +const thisModule = createModule(); +const token = DI_TOKENS.HASHED_LINK_SERVICE; +const moduleToken = DI_TOKENS.HASHED_LINK_SERVICE_MODULE; +const loadModule = bindModuleToClassOnToken({ + module: thisModule, + moduleToken, + token, + classs: HashedLinkService, + depsMap: { + hashedLinkRepository: hashedLinkRepositoryModuleLoader, + membershipService: membershipServiceModuleLoader, + }, +}); + +export const moduleLoader = { + token, + loadModule, +}; + +export type { HashedLinkService }; diff --git a/packages/features/hashedLink/di/tokens.ts b/packages/features/hashedLink/di/tokens.ts new file mode 100644 index 00000000000000..5aef36cdb25bc3 --- /dev/null +++ b/packages/features/hashedLink/di/tokens.ts @@ -0,0 +1,6 @@ +export const HASHED_LINK_DI_TOKENS = { + HASHED_LINK_REPOSITORY: Symbol("HashedLinkRepository"), + HASHED_LINK_REPOSITORY_MODULE: Symbol("HashedLinkRepositoryModule"), + HASHED_LINK_SERVICE: Symbol("HashedLinkService"), + HASHED_LINK_SERVICE_MODULE: Symbol("HashedLinkServiceModule"), +}; diff --git a/packages/features/hashedLink/repositories/hashedLinkRepository.ts b/packages/features/hashedLink/lib/repository/HashedLinkRepository.ts similarity index 100% rename from packages/features/hashedLink/repositories/hashedLinkRepository.ts rename to packages/features/hashedLink/lib/repository/HashedLinkRepository.ts diff --git a/packages/features/hashedLink/services/hashedLinkService.ts b/packages/features/hashedLink/lib/service/HashedLinkService.ts similarity index 85% rename from packages/features/hashedLink/services/hashedLinkService.ts rename to packages/features/hashedLink/lib/service/HashedLinkService.ts index 9d7fb178ed8b8d..62475a9223b83a 100644 --- a/packages/features/hashedLink/services/hashedLinkService.ts +++ b/packages/features/hashedLink/lib/service/HashedLinkService.ts @@ -1,10 +1,11 @@ -import { - HashedLinkRepository, - type HashedLinkInputType, -} from "@calcom/features/hashedLink/repositories/hashedLinkRepository"; import { MembershipService } from "@calcom/features/membership/services/membershipService"; import { ErrorCode } from "@calcom/lib/errorCodes"; import { validateHashedLinkData } from "@calcom/lib/hashedLinksUtils"; +import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; + +import { HashedLinkRepository } from "../repository/HashedLinkRepository"; +import { type HashedLinkInputType } from "../repository/HashedLinkRepository"; type NormalizedLink = { link: string; @@ -12,11 +13,19 @@ type NormalizedLink = { maxUsageCount?: number | null; }; +interface HashedLinkServiceDeps { + hashedLinkRepository: HashedLinkRepository; + membershipService: MembershipService; +} + export class HashedLinkService { - constructor( - private readonly hashedLinkRepository: HashedLinkRepository = HashedLinkRepository.create(), - private readonly membershipService: MembershipService = new MembershipService() - ) {} + private readonly hashedLinkRepository: HashedLinkRepository; + private readonly membershipService: MembershipService; + + constructor(deps?: HashedLinkServiceDeps) { + this.hashedLinkRepository = deps?.hashedLinkRepository ?? HashedLinkRepository.create(); + this.membershipService = deps?.membershipService ?? new MembershipService(); + } /** * Normalizes link input to a consistent format @@ -27,10 +36,10 @@ export class HashedLinkService { return typeof input === "string" ? { link: input, expiresAt: null } : { - link: input.link, - expiresAt: input.expiresAt ?? null, - maxUsageCount: input.maxUsageCount, - }; + link: input.link, + expiresAt: input.expiresAt ?? null, + maxUsageCount: input.maxUsageCount, + }; } /** @@ -114,7 +123,8 @@ export class HashedLinkService { if (hashedLink.maxUsageCount && hashedLink.maxUsageCount > 0) { try { await this.hashedLinkRepository.incrementUsage(hashedLink.id, hashedLink.maxUsageCount); - } catch (updateError) { + } catch (e) { + logger.error("Error incrementing usage for hashed link", safeStringify(e)); throw new Error(ErrorCode.PrivateLinkExpired); } } diff --git a/packages/features/users/di/MembershipRepository.module.ts b/packages/features/users/di/MembershipRepository.module.ts new file mode 100644 index 00000000000000..96b23634732312 --- /dev/null +++ b/packages/features/users/di/MembershipRepository.module.ts @@ -0,0 +1,22 @@ +import { bindModuleToClassOnToken, createModule, type ModuleLoader } from "@calcom/features/di/di"; +import { DI_TOKENS } from "@calcom/features/di/tokens"; +import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository"; +import { moduleLoader as prismaModuleLoader } from "@calcom/prisma/prisma.module"; + +export const membershipRepositoryModule = createModule(); +const token = DI_TOKENS.MEMBERSHIP_REPOSITORY; +const moduleToken = DI_TOKENS.MEMBERSHIP_REPOSITORY_MODULE; +const loadModule = bindModuleToClassOnToken({ + module: membershipRepositoryModule, + moduleToken, + token, + classs: MembershipRepository, + dep: prismaModuleLoader, +}); + +export const moduleLoader: ModuleLoader = { + token, + loadModule, +}; + +export type { MembershipRepository }; diff --git a/packages/features/users/di/MembershipService.module.ts b/packages/features/users/di/MembershipService.module.ts new file mode 100644 index 00000000000000..7be10194a3c1d0 --- /dev/null +++ b/packages/features/users/di/MembershipService.module.ts @@ -0,0 +1,25 @@ +import { bindModuleToClassOnToken, createModule, type ModuleLoader } from "@calcom/features/di/di"; +import { DI_TOKENS } from "@calcom/features/di/tokens"; +import { MembershipService } from "@calcom/features/membership/services/membershipService"; + +import { moduleLoader as membershipRepositoryModuleLoader } from "./MembershipRepository.module"; + +const thisModule = createModule(); +const token = DI_TOKENS.MEMBERSHIP_SERVICE; +const moduleToken = DI_TOKENS.MEMBERSHIP_SERVICE_MODULE; +const loadModule = bindModuleToClassOnToken({ + module: thisModule, + moduleToken, + token, + classs: MembershipService, + depsMap: { + membershipRepository: membershipRepositoryModuleLoader, + }, +}); + +export const moduleLoader: ModuleLoader = { + token, + loadModule, +}; + +export type { MembershipService }; diff --git a/packages/trpc/server/routers/viewer/eventTypes/getHashedLink.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/getHashedLink.handler.ts index 25544d25a44781..fd3e6d3145af41 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/getHashedLink.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/getHashedLink.handler.ts @@ -1,5 +1,5 @@ -import { HashedLinkRepository } from "@calcom/features/hashedLink/repositories/hashedLinkRepository"; -import { HashedLinkService } from "@calcom/features/hashedLink/services/hashedLinkService"; +import { HashedLinkRepository } from "@calcom/features/hashedLink/lib/repository/HashedLinkRepository"; +import { HashedLinkService } from "@calcom/features/hashedLink/lib/service/HashedLinkService"; import type { PrismaClient } from "@calcom/prisma"; import type { TrpcSessionUser } from "@calcom/trpc/server/types"; diff --git a/packages/trpc/server/routers/viewer/eventTypes/getHashedLinks.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/getHashedLinks.handler.ts index 7c783b4773d253..ad7c5ef2dfd7d2 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/getHashedLinks.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/getHashedLinks.handler.ts @@ -1,5 +1,5 @@ -import { HashedLinkRepository } from "@calcom/features/hashedLink/repositories/hashedLinkRepository"; -import { HashedLinkService } from "@calcom/features/hashedLink/services/hashedLinkService"; +import { HashedLinkRepository } from "@calcom/features/hashedLink/lib/repository/HashedLinkRepository"; +import { HashedLinkService } from "@calcom/features/hashedLink/lib/service/HashedLinkService"; import type { PrismaClient } from "@calcom/prisma"; import type { TrpcSessionUser } from "@calcom/trpc/server/types"; diff --git a/packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts index bca595b4ea4e81..439d77a5e5cb41 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts @@ -8,8 +8,8 @@ import { allowDisablingAttendeeConfirmationEmails, allowDisablingHostConfirmationEmails, } from "@calcom/features/ee/workflows/lib/allowDisablingStandardEmails"; -import { HashedLinkRepository } from "@calcom/features/hashedLink/repositories/hashedLinkRepository"; -import { HashedLinkService } from "@calcom/features/hashedLink/services/hashedLinkService"; +import { HashedLinkRepository } from "@calcom/features/hashedLink/lib/repository/HashedLinkRepository"; +import { HashedLinkService } from "@calcom/features/hashedLink/lib/service/HashedLinkService"; import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository"; import tasker from "@calcom/features/tasker"; import { validateIntervalLimitOrder } from "@calcom/lib/intervalLimits/validateIntervalLimitOrder"; @@ -523,7 +523,11 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { trigger: WorkflowTriggerEvents.NEW_EVENT, }, include: { - steps: true, + steps: { + select: { + action: true, + }, + }, }, }); @@ -630,7 +634,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { const isCalVideoLocationActive = locations ? locations.some((location) => location.type === DailyLocationType) : parsedEventTypeLocations.success && - parsedEventTypeLocations.data?.some((location) => location.type === DailyLocationType); + parsedEventTypeLocations.data?.some((location) => location.type === DailyLocationType); if (eventType.calVideoSettings && !isCalVideoLocationActive) { await CalVideoSettingsRepository.deleteCalVideoSettings(id); From ceb83117b00c5d8eaa4908c4ebdaca07b7de78a8 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 22 Oct 2025 11:10:31 +0000 Subject: [PATCH 2/5] refactor: improve ISP compliance in BookingEventHandlerService - Refactor updatePrivateLinkUsage to only accept hashedLink parameter instead of full payload - Remove shouldProcess function and inline isDryRun check for better clarity - Addresses feedback from PR #24025 Co-Authored-By: hariom@cal.com --- .../BookingEventHandlerService.ts | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/packages/features/bookings/lib/onBookingEvents/BookingEventHandlerService.ts b/packages/features/bookings/lib/onBookingEvents/BookingEventHandlerService.ts index 6fb3b5acf6da94..68e8aa5d845ced 100644 --- a/packages/features/bookings/lib/onBookingEvents/BookingEventHandlerService.ts +++ b/packages/features/bookings/lib/onBookingEvents/BookingEventHandlerService.ts @@ -21,7 +21,7 @@ export class BookingEventHandlerService { async onBookingCreated(payload: BookingCreatedPayload) { this.log.debug("onBookingCreated", safeStringify(payload)); - if (!this.shouldProcess(payload)) { + if (payload.config.isDryRun) { return; } await this.onBookingCreatedOrRescheduled(payload); @@ -29,7 +29,7 @@ export class BookingEventHandlerService { async onBookingRescheduled(payload: BookingRescheduledPayload) { this.log.debug("onBookingRescheduled", safeStringify(payload)); - if (!this.shouldProcess(payload)) { + if (payload.config.isDryRun) { return; } await this.onBookingCreatedOrRescheduled(payload); @@ -42,7 +42,7 @@ export class BookingEventHandlerService { private async onBookingCreatedOrRescheduled(payload: BookingCreatedPayload | BookingRescheduledPayload) { const results = await Promise.allSettled([ // TODO: Migrate other post-booking tasks here, to execute them in parallel, without affecting each other - this.updatePrivateLinkUsage(payload), + this.updatePrivateLinkUsage(payload.bookingFormData.hashedLink), ]); results.forEach((result) => { if (result.status === "rejected") { @@ -54,17 +54,13 @@ export class BookingEventHandlerService { }); } - private async updatePrivateLinkUsage(payload: BookingCreatedPayload | BookingRescheduledPayload) { + private async updatePrivateLinkUsage(hashedLink: string | null) { try { - if (payload.bookingFormData.hashedLink) { - await this.deps.hashedLinkService.validateAndIncrementUsage(payload.bookingFormData.hashedLink); + if (hashedLink) { + await this.deps.hashedLinkService.validateAndIncrementUsage(hashedLink); } } catch (error) { this.log.error("Error while updating hashed link", safeStringify(error)); } } - - private shouldProcess(payload: BookingCreatedPayload | BookingRescheduledPayload) { - return !payload.config.isDryRun; - } } From 46be34f6faebd87ad1020c519787f8129c4b329c Mon Sep 17 00:00:00 2001 From: Hariom Balhara Date: Wed, 22 Oct 2025 19:21:01 +0530 Subject: [PATCH 3/5] define only what is used --- .../bookings/lib/onBookingEvents/types.d.ts | 32 +--------- .../lib/service/RegularBookingService.ts | 62 ++++++++----------- 2 files changed, 28 insertions(+), 66 deletions(-) diff --git a/packages/features/bookings/lib/onBookingEvents/types.d.ts b/packages/features/bookings/lib/onBookingEvents/types.d.ts index a0a306a4c183ae..9170dc0c869ff3 100644 --- a/packages/features/bookings/lib/onBookingEvents/types.d.ts +++ b/packages/features/bookings/lib/onBookingEvents/types.d.ts @@ -1,39 +1,11 @@ -import type { BookingStatus, SchedulingType } from "@calcom/prisma/enums"; - import type { BookingFlowConfig } from "./dto/types"; -type BookingEventType = { - id: number; - slug: string; - schedulingType: SchedulingType | null; - metadata: Record | null; - hashedLink?: string; -}; - -type CreatedBooking = { - uid: string; - userId: number | null; - status: BookingStatus; - startTime: Date; - endTime: Date; - location: string | null; -}; - export interface BookingCreatedPayload { - booking: CreatedBooking; - eventType: BookingEventType; config: BookingFlowConfig; bookingFormData: { hashedLink: string | null; }; } -export interface BookingRescheduledPayload extends BookingCreatedPayload { - reschedule: { - originalBooking: { - uid: string; - }; - rescheduleReason: string | null; - rescheduledBy: string | null; - }; -} +// Add more fields here when needed +type BookingRescheduledPayload = BookingCreatedPayload; diff --git a/packages/features/bookings/lib/service/RegularBookingService.ts b/packages/features/bookings/lib/service/RegularBookingService.ts index 8d3648ec403f99..ce9d4162845836 100644 --- a/packages/features/bookings/lib/service/RegularBookingService.ts +++ b/packages/features/bookings/lib/service/RegularBookingService.ts @@ -38,12 +38,13 @@ import type { CacheService } from "@calcom/features/calendar-cache/lib/getShould 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 { WorkflowService } from "@calcom/features/ee/workflows/lib/service/WorkflowService"; 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 { getFullName } from "@calcom/features/form-builder/utils"; -import { ProfileRepository } from "@calcom/features/profile/repositories/ProfileRepository"; import type { HashedLinkService } from "@calcom/features/hashedLink/lib/service/HashedLinkService"; +import { ProfileRepository } from "@calcom/features/profile/repositories/ProfileRepository"; 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"; @@ -69,7 +70,6 @@ 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 { WorkflowService } from "@calcom/features/ee/workflows/lib/service/WorkflowService"; import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat"; import type { PrismaClient } from "@calcom/prisma"; import type { DestinationCalendar, Prisma, User, AssignmentReasonEnum } from "@calcom/prisma/client"; @@ -1256,9 +1256,9 @@ async function handler( // This ensures that createMeeting isn't called for static video apps as bookingLocation becomes just a regular value for them. const { bookingLocation, conferenceCredentialId } = organizerOrFirstDynamicGroupMemberDefaultLocationUrl ? { - bookingLocation: organizerOrFirstDynamicGroupMemberDefaultLocationUrl, - conferenceCredentialId: undefined, - } + bookingLocation: organizerOrFirstDynamicGroupMemberDefaultLocationUrl, + conferenceCredentialId: undefined, + } : getLocationValueForDB(locationBodyString, eventType.locations); log.info("locationBodyString", locationBodyString); @@ -1304,8 +1304,8 @@ async function handler( const destinationCalendar = eventType.destinationCalendar ? [eventType.destinationCalendar] : organizerUser.destinationCalendar - ? [organizerUser.destinationCalendar] - : null; + ? [organizerUser.destinationCalendar] + : null; let organizerEmail = organizerUser.email || "Email-less"; if (eventType.useEventTypeDestinationCalendarEmail && destinationCalendar?.[0]?.primaryEmail) { @@ -1924,14 +1924,14 @@ async function handler( } const updateManager = !skipCalendarSyncTaskCreation ? await eventManager.reschedule( - evt, - originalRescheduledBooking.uid, - undefined, - changedOrganizer, - previousHostDestinationCalendar, - isBookingRequestedReschedule, - skipDeleteEventsAndMeetings - ) + evt, + originalRescheduledBooking.uid, + undefined, + changedOrganizer, + previousHostDestinationCalendar, + isBookingRequestedReschedule, + skipDeleteEventsAndMeetings + ) : placeholderCreatedEvent; // This gets overridden when updating the event - to check if notes have been hidden or not. We just reset this back // to the default description when we are sending the emails. @@ -2229,8 +2229,8 @@ async function handler( const metadata = videoCallUrl ? { - videoCallUrl: getVideoCallUrlFromCalEvent(evt) || videoCallUrl, - } + videoCallUrl: getVideoCallUrlFromCalEvent(evt) || videoCallUrl, + } : undefined; const bookingFlowConfig = { @@ -2238,8 +2238,6 @@ async function handler( }; const bookingCreatedPayload = { - booking, - eventType, config: bookingFlowConfig, bookingFormData: { // FIXME: It looks like hasHashedBookingLink is set to true based on the value of hashedLink when sending the request. So, technically we could remove hasHashedBookingLink usage completely @@ -2247,6 +2245,9 @@ async function handler( }, }; + // Add more fields here when needed + const bookingRescheduledPayload = bookingCreatedPayload; + const bookingEventHandler = new BookingEventHandlerService({ log: loggerWithEventDetails, hashedLinkService: deps.hashedLinkService, @@ -2254,20 +2255,9 @@ async function handler( // TODO: Incrementally move all stuff that happens after a booking is created to these handlers if (originalRescheduledBooking) { - await bookingEventHandler.onBookingRescheduled({ - ...bookingCreatedPayload, - reschedule: { - originalBooking: { - uid: originalRescheduledBooking.uid, - }, - rescheduleReason: rescheduleReason ?? null, - rescheduledBy: reqBody.rescheduledBy ?? null, - }, - }); + await bookingEventHandler.onBookingRescheduled(bookingRescheduledPayload); } else { - await bookingEventHandler.onBookingCreated({ - ...bookingCreatedPayload, - }); + await bookingEventHandler.onBookingCreated(bookingCreatedPayload); } const webhookData: EventPayloadType = { @@ -2329,9 +2319,9 @@ async function handler( ...eventType, metadata: eventType.metadata ? { - ...eventType.metadata, - apps: eventType.metadata?.apps as Prisma.JsonValue, - } + ...eventType.metadata, + apps: eventType.metadata?.apps as Prisma.JsonValue, + } : {}, }, paymentAppCredentials: eventTypePaymentAppCredential as IEventTypePaymentCredentialType, @@ -2628,7 +2618,7 @@ async function handler( * We are open to renaming it to something more descriptive. */ export class RegularBookingService implements IBookingService { - constructor(private readonly deps: IBookingServiceDependencies) { } + constructor(private readonly deps: IBookingServiceDependencies) {} async createBooking(input: { bookingData: CreateRegularBookingData; bookingMeta?: CreateBookingMeta }) { return handler({ bookingData: input.bookingData, ...input.bookingMeta }, this.deps); From 289dc2023f0fe3c9020410424539b7d4168acdf7 Mon Sep 17 00:00:00 2001 From: Hariom Balhara Date: Thu, 23 Oct 2025 09:50:44 +0530 Subject: [PATCH 4/5] Define hashed-link-service as well in api-v2 --- apps/api/v2/src/lib/modules/regular-booking.module.ts | 4 +++- apps/api/v2/src/lib/services/hashed-link.service.ts | 11 +++++++++++ .../v2/src/lib/services/regular-booking.service.ts | 3 +++ 3 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 apps/api/v2/src/lib/services/hashed-link.service.ts diff --git a/apps/api/v2/src/lib/modules/regular-booking.module.ts b/apps/api/v2/src/lib/modules/regular-booking.module.ts index 01618100d1016a..0cdc705579dd1a 100644 --- a/apps/api/v2/src/lib/modules/regular-booking.module.ts +++ b/apps/api/v2/src/lib/modules/regular-booking.module.ts @@ -7,6 +7,7 @@ import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository" import { CacheService } from "@/lib/services/cache.service"; import { CheckBookingAndDurationLimitsService } from "@/lib/services/check-booking-and-duration-limits.service"; import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service"; +import { HashedLinkService } from "@/lib/services/hashed-link.service"; import { LuckyUserService } from "@/lib/services/lucky-user.service"; import { RegularBookingService } from "@/lib/services/regular-booking.service"; import { PrismaModule } from "@/modules/prisma/prisma.module"; @@ -24,9 +25,10 @@ import { Module } from "@nestjs/common"; CacheService, CheckBookingAndDurationLimitsService, CheckBookingLimitsService, + HashedLinkService, LuckyUserService, RegularBookingService, ], exports: [RegularBookingService], }) -export class RegularBookingModule {} +export class RegularBookingModule { } diff --git a/apps/api/v2/src/lib/services/hashed-link.service.ts b/apps/api/v2/src/lib/services/hashed-link.service.ts new file mode 100644 index 00000000000000..8c8c3fd02362d8 --- /dev/null +++ b/apps/api/v2/src/lib/services/hashed-link.service.ts @@ -0,0 +1,11 @@ +import { Injectable } from "@nestjs/common"; + +import { HashedLinkService as BaseHashedLinkService } from "@calcom/features/hashedLink/lib/service/HashedLinkService"; + +@Injectable() +export class HashedLinkService extends BaseHashedLinkService { + constructor() { + super(); + } +} + diff --git a/apps/api/v2/src/lib/services/regular-booking.service.ts b/apps/api/v2/src/lib/services/regular-booking.service.ts index 39ca6b2c156937..d2b05337f80b7b 100644 --- a/apps/api/v2/src/lib/services/regular-booking.service.ts +++ b/apps/api/v2/src/lib/services/regular-booking.service.ts @@ -2,6 +2,7 @@ import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repos import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository"; import { CacheService } from "@/lib/services/cache.service"; import { CheckBookingAndDurationLimitsService } from "@/lib/services/check-booking-and-duration-limits.service"; +import { HashedLinkService } from "@/lib/services/hashed-link.service"; import { LuckyUserService } from "@/lib/services/lucky-user.service"; import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; import { Injectable } from "@nestjs/common"; @@ -16,6 +17,7 @@ export class RegularBookingService extends BaseRegularBookingService { checkBookingAndDurationLimitsService: CheckBookingAndDurationLimitsService, prismaWriteService: PrismaWriteService, bookingRepository: PrismaBookingRepository, + hashedLinkService: HashedLinkService, luckyUserService: LuckyUserService, userRepository: PrismaUserRepository ) { @@ -24,6 +26,7 @@ export class RegularBookingService extends BaseRegularBookingService { checkBookingAndDurationLimitsService, prismaClient: prismaWriteService.prisma as unknown as PrismaClient, bookingRepository, + hashedLinkService, luckyUserService, userRepository, }); From 23c6da2e4b5c279c7564e8bf9d8d6559e84145b8 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 23 Oct 2025 06:16:54 +0000 Subject: [PATCH 5/5] fix: export HashedLinkService through platform-libraries - Add HashedLinkService to platform-libraries/private-links.ts - Update API V2 to import from platform library instead of direct path - Fixes MODULE_NOT_FOUND error in API V2 build Co-Authored-By: hariom@cal.com --- apps/api/v2/src/lib/services/hashed-link.service.ts | 9 ++++----- packages/platform/libraries/private-links.ts | 1 + 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/api/v2/src/lib/services/hashed-link.service.ts b/apps/api/v2/src/lib/services/hashed-link.service.ts index 8c8c3fd02362d8..bd1882e6fbd559 100644 --- a/apps/api/v2/src/lib/services/hashed-link.service.ts +++ b/apps/api/v2/src/lib/services/hashed-link.service.ts @@ -1,11 +1,10 @@ import { Injectable } from "@nestjs/common"; -import { HashedLinkService as BaseHashedLinkService } from "@calcom/features/hashedLink/lib/service/HashedLinkService"; +import { HashedLinkService as BaseHashedLinkService } from "@calcom/platform-libraries/private-links"; @Injectable() export class HashedLinkService extends BaseHashedLinkService { - constructor() { - super(); - } + constructor() { + super(); + } } - diff --git a/packages/platform/libraries/private-links.ts b/packages/platform/libraries/private-links.ts index 45dd87e2c63042..e11cf0112e6ae3 100644 --- a/packages/platform/libraries/private-links.ts +++ b/packages/platform/libraries/private-links.ts @@ -1,2 +1,3 @@ export { generateHashedLink } from "@calcom/lib/generateHashedLink"; export { isLinkExpired } from "@calcom/lib/hashedLinksUtils"; +export { HashedLinkService } from "@calcom/features/hashedLink/lib/service/HashedLinkService";