diff --git a/packages/features/bookings/lib/handleCancelBooking.ts b/packages/features/bookings/lib/handleCancelBooking.ts index 483b9e919c5135..71c475a7704dcc 100644 --- a/packages/features/bookings/lib/handleCancelBooking.ts +++ b/packages/features/bookings/lib/handleCancelBooking.ts @@ -162,6 +162,7 @@ async function handler(input: CancelBookingInput, dependencies?: Dependencies) { cancelSubsequentBookings, internalNote, skipCancellationReasonValidation = false, + skipCalendarSyncTaskCancellation = false, } = bookingCancelInput.parse(body); const bookingToDelete = await getBookingToDelete(id, uid); const { @@ -610,36 +611,46 @@ async function handler(input: CancelBookingInput, dependencies?: Dependencies) { allRemainingBookings ); - try { - const bookingToDeleteEventTypeMetadataParsed = eventTypeMetaDataSchemaWithTypedApps.safeParse( - bookingToDelete.eventType?.metadata || null - ); - - if (!bookingToDeleteEventTypeMetadataParsed.success) { - log.error( - `Error parsing metadata`, - safeStringify({ error: bookingToDeleteEventTypeMetadataParsed?.error }) + // Skip calendar event deletion when cancellation comes from a calendar subscription webhook + // to avoid infinite loops (Google/Office365 → Cal.com → Google/Office365 → ...) + if (!skipCalendarSyncTaskCancellation) { + try { + const bookingToDeleteEventTypeMetadataParsed = eventTypeMetaDataSchemaWithTypedApps.safeParse( + bookingToDelete.eventType?.metadata || null ); - throw new Error("Error parsing metadata"); - } - const bookingToDeleteEventTypeMetadata = bookingToDeleteEventTypeMetadataParsed.data; + if (!bookingToDeleteEventTypeMetadataParsed.success) { + log.error( + `Error parsing metadata`, + safeStringify({ error: bookingToDeleteEventTypeMetadataParsed?.error }) + ); + throw new Error("Error parsing metadata"); + } - const credentials = await getAllCredentialsIncludeServiceAccountKey(bookingToDelete.user, { - ...bookingToDelete.eventType, - metadata: bookingToDeleteEventTypeMetadata, - }); + const bookingToDeleteEventTypeMetadata = bookingToDeleteEventTypeMetadataParsed.data; - const eventManager = new EventManager( - { ...bookingToDelete.user, credentials }, - bookingToDeleteEventTypeMetadata?.apps - ); + const credentials = await getAllCredentialsIncludeServiceAccountKey(bookingToDelete.user, { + ...bookingToDelete.eventType, + metadata: bookingToDeleteEventTypeMetadata, + }); + + const eventManager = new EventManager( + { ...bookingToDelete.user, credentials }, + bookingToDeleteEventTypeMetadata?.apps + ); - await eventManager.cancelEvent(evt, bookingToDelete.references, isBookingInRecurringSeries); + await eventManager.cancelEvent(evt, bookingToDelete.references, isBookingInRecurringSeries); + } catch (error) { + log.error(`Error deleting integrations`, safeStringify({ error })); + } + } + // Always mark booking references as deleted for data consistency + // (even when skipCalendarSyncTaskCancellation is true, since the external event is already deleted) + try { await bookingReferenceRepository.updateManyByBookingId(bookingToDelete.id, { deleted: true }); } catch (error) { - log.error(`Error deleting integrations`, safeStringify({ error })); + log.error(`Error marking booking references as deleted`, safeStringify({ error })); } try { diff --git a/packages/features/calendar-subscription/lib/sync/CalendarSyncService.ts b/packages/features/calendar-subscription/lib/sync/CalendarSyncService.ts index 2a2aff77f37c59..906fd9d935972f 100644 --- a/packages/features/calendar-subscription/lib/sync/CalendarSyncService.ts +++ b/packages/features/calendar-subscription/lib/sync/CalendarSyncService.ts @@ -1,7 +1,10 @@ -import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository"; +import handleCancelBooking from "@calcom/features/bookings/lib/handleCancelBooking"; +import type { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository"; import type { CalendarSubscriptionEventItem } from "@calcom/features/calendar-subscription/lib/CalendarSubscriptionPort.interface"; import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; import type { SelectedCalendar } from "@calcom/prisma/client"; +import { metrics } from "@sentry/nextjs"; const log = logger.getSubLogger({ prefix: ["CalendarSyncService"] }); @@ -31,10 +34,23 @@ export class CalendarSyncService { countEvents: calendarSubscriptionEvents.length, }); + metrics.count("calendar.sync.handleEvents.calls", 1, { + attributes: { + integration: selectedCalendar.integration, + }, + }); + // only process cal.com calendar events const calEvents = calendarSubscriptionEvents.filter((e) => e.iCalUID?.toLowerCase()?.endsWith("@cal.com") ); + + metrics.distribution("calendar.sync.handleEvents.events_count", calEvents.length, { + attributes: { + integration: selectedCalendar.integration, + }, + }); + if (calEvents.length === 0) { log.debug("handleEvents: no calendar events to process"); return; @@ -59,20 +75,63 @@ export class CalendarSyncService { * @returns */ async cancelBooking(event: CalendarSubscriptionEventItem) { + const startTime = performance.now(); log.debug("cancelBooking", { event }); const [bookingUid] = event.iCalUID?.split("@") ?? [undefined]; if (!bookingUid) { - log.debug("Unable to sync, booking not found"); + log.debug("Unable to sync, booking UID not found in iCalUID"); return; } const booking = await this.deps.bookingRepository.findBookingByUidWithEventType({ bookingUid }); if (!booking) { - log.debug("Unable to sync, booking not found"); + log.debug("Unable to sync, booking not found in database", { bookingUid }); return; } - // todo handle cancel booking + if (!booking.userId || !booking.userPrimaryEmail) { + log.warn("Unable to sync cancellation, booking missing required user data", { + bookingUid, + hasUserId: !!booking.userId, + hasUserPrimaryEmail: !!booking.userPrimaryEmail, + }); + metrics.count("calendar.sync.cancelBooking.calls", 1, { + attributes: { status: "skipped", reason: "missing_user_data" }, + }); + return; + } + + try { + await handleCancelBooking({ + userId: booking.userId, + bookingData: { + uid: booking.uid, + cancellationReason: "Cancelled on user's calendar", + cancelledBy: booking.userPrimaryEmail, + // Skip calendar event deletion to avoid infinite loops + // (Google/Office365 → Cal.com → Google/Office365 → ...) + skipCalendarSyncTaskCancellation: true, + }, + }); + log.info("Successfully cancelled booking from calendar sync", { bookingUid }); + + metrics.count("calendar.sync.cancelBooking.calls", 1, { + attributes: { status: "success" }, + }); + metrics.distribution("calendar.sync.cancelBooking.duration_ms", performance.now() - startTime, { + attributes: { status: "success" }, + }); + } catch (error) { + // Log error but don't block - calendar change should still be reflected + log.error("Failed to cancel booking from calendar sync", { bookingUid, error: safeStringify(error) }); + + metrics.count("calendar.sync.cancelBooking.calls", 1, { + attributes: { status: "error" }, + }); + metrics.distribution("calendar.sync.cancelBooking.duration_ms", performance.now() - startTime, { + attributes: { status: "error" }, + }); + } } /** @@ -80,19 +139,72 @@ export class CalendarSyncService { * @param event */ async rescheduleBooking(event: CalendarSubscriptionEventItem) { + const startTime = performance.now(); log.debug("rescheduleBooking", { event }); const [bookingUid] = event.iCalUID?.split("@") ?? [undefined]; if (!bookingUid) { - log.debug("Unable to sync, booking not found"); + log.debug("Unable to sync, booking UID not found in iCalUID"); return; } const booking = await this.deps.bookingRepository.findBookingByUidWithEventType({ bookingUid }); if (!booking) { - log.debug("Unable to sync, booking not found"); + log.debug("Unable to sync, booking not found in database", { bookingUid }); + return; + } + + if (!booking.eventTypeId) { + log.warn("Unable to sync reschedule, booking missing eventTypeId", { bookingUid }); + metrics.count("calendar.sync.rescheduleBooking.calls", 1, { + attributes: { status: "skipped", reason: "missing_event_type_id" }, + }); return; } - // todo handle update booking + try { + // Dynamic import to avoid loading the entire booking service chain at module evaluation time + // This prevents react-awesome-query-builder from being loaded in server-side contexts + const { getRegularBookingService } = await import( + "@calcom/features/bookings/di/RegularBookingService.container" + ); + const regularBookingService = getRegularBookingService(); + await regularBookingService.createBooking({ + bookingData: { + eventTypeId: booking.eventTypeId, + start: event.start?.toISOString() ?? booking.startTime.toISOString(), + end: event.end?.toISOString() ?? booking.endTime.toISOString(), + timeZone: event.timeZone ?? "UTC", + language: "en", + metadata: {}, + rescheduleUid: booking.uid, + }, + bookingMeta: { + // Skip calendar event creation to avoid infinite loops + // (Google/Office365 → Cal.com → Google/Office365 → ...) + skipCalendarSyncTaskCreation: true, + }, + }); + log.info("Successfully rescheduled booking from calendar sync", { bookingUid }); + + metrics.count("calendar.sync.rescheduleBooking.calls", 1, { + attributes: { status: "success" }, + }); + metrics.distribution("calendar.sync.rescheduleBooking.duration_ms", performance.now() - startTime, { + attributes: { status: "success" }, + }); + } catch (error) { + // Log error but don't block - calendar change should still be reflected + log.error("Failed to reschedule booking from calendar sync", { + bookingUid, + error: safeStringify(error), + }); + + metrics.count("calendar.sync.rescheduleBooking.calls", 1, { + attributes: { status: "error" }, + }); + metrics.distribution("calendar.sync.rescheduleBooking.duration_ms", performance.now() - startTime, { + attributes: { status: "error" }, + }); + } } } diff --git a/packages/features/calendar-subscription/lib/sync/__tests__/CalendarSyncService.test.ts b/packages/features/calendar-subscription/lib/sync/__tests__/CalendarSyncService.test.ts new file mode 100644 index 00000000000000..c353c131063bca --- /dev/null +++ b/packages/features/calendar-subscription/lib/sync/__tests__/CalendarSyncService.test.ts @@ -0,0 +1,368 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import type { CalendarSubscriptionEventItem } from "@calcom/features/calendar-subscription/lib/CalendarSubscriptionPort.interface"; +import type { BookingRepository } from "@calcom/lib/server/repository/booking"; +import type { SelectedCalendar } from "@calcom/prisma/client"; + +import { CalendarSyncService } from "../CalendarSyncService"; + +const { mockHandleCancelBooking, mockCreateBooking } = vi.hoisted(() => ({ + mockHandleCancelBooking: vi.fn().mockResolvedValue(undefined), + mockCreateBooking: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@calcom/features/bookings/lib/handleCancelBooking", () => ({ + default: mockHandleCancelBooking, +})); + +vi.mock("@calcom/features/bookings/di/RegularBookingService.container", () => ({ + getRegularBookingService: () => ({ + createBooking: mockCreateBooking, + }), +})); + +const mockSelectedCalendar: SelectedCalendar = { + id: "test-calendar-id", + userId: 1, + credentialId: 1, + integration: "google_calendar", + externalId: "test@example.com", + eventTypeId: null, + delegationCredentialId: null, + domainWideDelegationCredentialId: null, + googleChannelId: null, + googleChannelKind: null, + googleChannelResourceId: null, + googleChannelResourceUri: null, + googleChannelExpiration: null, + error: null, + lastErrorAt: null, + watchAttempts: 0, + maxAttempts: 3, + unwatchAttempts: 0, + createdAt: new Date(), + updatedAt: new Date(), + channelId: "test-channel-id", + channelKind: "web_hook", + channelResourceId: "test-resource-id", + channelResourceUri: "test-resource-uri", + channelExpiration: new Date(Date.now() + 86400000), + syncSubscribedAt: new Date(), + syncToken: "test-sync-token", + syncedAt: new Date(), + syncErrorAt: null, + syncErrorCount: 0, +}; + +const mockBooking = { + id: 1, + uid: "test-booking-uid", + userId: 1, + userPrimaryEmail: "user@example.com", + startTime: new Date("2023-12-01T10:00:00Z"), + endTime: new Date("2023-12-01T11:00:00Z"), + eventTypeId: 1, + eventType: { + id: 1, + title: "Test Event Type", + }, +}; + +const mockCalComEvent: CalendarSubscriptionEventItem = { + id: "event-1", + iCalUID: "test-booking-uid@cal.com", + start: new Date("2023-12-01T10:00:00Z"), + end: new Date("2023-12-01T11:00:00Z"), + busy: true, + summary: "Test Event", + description: "Test Description", + location: "Test Location", + status: "confirmed", + isAllDay: false, + timeZone: "UTC", + recurringEventId: null, + originalStartDate: null, + createdAt: new Date(), + updatedAt: new Date(), + etag: "test-etag", + kind: "calendar#event", +}; + +const mockNonCalComEvent: CalendarSubscriptionEventItem = { + id: "event-2", + iCalUID: "external-event@external.com", + start: new Date("2023-12-01T12:00:00Z"), + end: new Date("2023-12-01T13:00:00Z"), + busy: true, + summary: "External Event", + description: "External Description", + location: "External Location", + status: "confirmed", + isAllDay: false, + timeZone: "UTC", + recurringEventId: null, + originalStartDate: null, + createdAt: new Date(), + updatedAt: new Date(), + etag: "test-etag", + kind: "calendar#event", +}; + +const mockCancelledEvent: CalendarSubscriptionEventItem = { + ...mockCalComEvent, + id: "event-3", + iCalUID: "cancelled-booking-uid@cal.com", + status: "cancelled", +}; + +describe("CalendarSyncService", () => { + let service: CalendarSyncService; + let mockBookingRepository: BookingRepository; + + beforeEach(() => { + mockBookingRepository = { + findBookingByUidWithEventType: vi.fn(), + } as unknown as BookingRepository; + + service = new CalendarSyncService({ + bookingRepository: mockBookingRepository, + }); + + vi.clearAllMocks(); + }); + + describe("handleEvents", () => { + test("should process only Cal.com events", async () => { + const events = [mockCalComEvent, mockNonCalComEvent, mockCancelledEvent]; + + mockBookingRepository.findBookingByUidWithEventType = vi + .fn() + .mockResolvedValueOnce(mockBooking) + .mockResolvedValueOnce(mockBooking); + + await service.handleEvents(mockSelectedCalendar, events); + + expect(mockBookingRepository.findBookingByUidWithEventType).toHaveBeenCalledTimes(2); + expect(mockBookingRepository.findBookingByUidWithEventType).toHaveBeenCalledWith({ + bookingUid: "test-booking-uid", + }); + expect(mockBookingRepository.findBookingByUidWithEventType).toHaveBeenCalledWith({ + bookingUid: "cancelled-booking-uid", + }); + }); + + test("should return early when no Cal.com events", async () => { + const events = [mockNonCalComEvent]; + + await service.handleEvents(mockSelectedCalendar, events); + + expect(mockBookingRepository.findBookingByUidWithEventType).not.toHaveBeenCalled(); + }); + + test("should return early when no events", async () => { + await service.handleEvents(mockSelectedCalendar, []); + + expect(mockBookingRepository.findBookingByUidWithEventType).not.toHaveBeenCalled(); + }); + + test("should handle mixed case iCalUID", async () => { + const eventWithMixedCase: CalendarSubscriptionEventItem = { + ...mockCalComEvent, + iCalUID: "test-booking-uid@CAL.COM", + }; + + mockBookingRepository.findBookingByUidWithEventType = vi.fn().mockResolvedValue(mockBooking); + + await service.handleEvents(mockSelectedCalendar, [eventWithMixedCase]); + + expect(mockBookingRepository.findBookingByUidWithEventType).toHaveBeenCalledWith({ + bookingUid: "test-booking-uid", + }); + }); + + test("should handle events with null iCalUID", async () => { + const eventWithNullUID: CalendarSubscriptionEventItem = { + ...mockCalComEvent, + iCalUID: null, + }; + + await service.handleEvents(mockSelectedCalendar, [eventWithNullUID]); + + expect(mockBookingRepository.findBookingByUidWithEventType).not.toHaveBeenCalled(); + }); + }); + + describe("cancelBooking", () => { + test("should successfully cancel a booking", async () => { + mockBookingRepository.findBookingByUidWithEventType = vi.fn().mockResolvedValue(mockBooking); + + await service.cancelBooking(mockCancelledEvent); + + expect(mockBookingRepository.findBookingByUidWithEventType).toHaveBeenCalledWith({ + bookingUid: "cancelled-booking-uid", + }); + + expect(mockHandleCancelBooking).toHaveBeenCalledWith({ + userId: mockBooking.userId, + bookingData: { + uid: mockBooking.uid, + cancellationReason: "Cancelled on user's calendar", + cancelledBy: mockBooking.userPrimaryEmail, + skipCalendarSyncTaskCancellation: true, + }, + }); + }); + + test("should return early when booking UID is missing", async () => { + const eventWithoutUID: CalendarSubscriptionEventItem = { + ...mockCancelledEvent, + iCalUID: null, + }; + + await service.cancelBooking(eventWithoutUID); + + expect(mockBookingRepository.findBookingByUidWithEventType).not.toHaveBeenCalled(); + expect(mockHandleCancelBooking).not.toHaveBeenCalled(); + }); + + test("should return early when booking UID is malformed", async () => { + const eventWithMalformedUID: CalendarSubscriptionEventItem = { + ...mockCancelledEvent, + iCalUID: "@cal.com", + }; + + await service.cancelBooking(eventWithMalformedUID); + + expect(mockBookingRepository.findBookingByUidWithEventType).not.toHaveBeenCalled(); + expect(mockHandleCancelBooking).not.toHaveBeenCalled(); + }); + + test("should return early when booking is not found", async () => { + mockBookingRepository.findBookingByUidWithEventType = vi.fn().mockResolvedValue(null); + + await service.cancelBooking(mockCancelledEvent); + + expect(mockBookingRepository.findBookingByUidWithEventType).toHaveBeenCalledWith({ + bookingUid: "cancelled-booking-uid", + }); + expect(mockHandleCancelBooking).not.toHaveBeenCalled(); + }); + + test("should handle cancellation errors gracefully without throwing", async () => { + mockBookingRepository.findBookingByUidWithEventType = vi.fn().mockResolvedValue(mockBooking); + mockHandleCancelBooking.mockRejectedValue(new Error("Cancellation failed")); + + // Should not throw - errors are caught and logged + await expect(service.cancelBooking(mockCancelledEvent)).resolves.not.toThrow(); + + expect(mockBookingRepository.findBookingByUidWithEventType).toHaveBeenCalled(); + expect(mockHandleCancelBooking).toHaveBeenCalled(); + }); + }); + + describe("rescheduleBooking", () => { + test("should successfully reschedule a booking", async () => { + const updatedEvent: CalendarSubscriptionEventItem = { + ...mockCalComEvent, + start: new Date("2023-12-01T14:00:00Z"), + end: new Date("2023-12-01T15:00:00Z"), + }; + + mockBookingRepository.findBookingByUidWithEventType = vi.fn().mockResolvedValue(mockBooking); + + await service.rescheduleBooking(updatedEvent); + + expect(mockBookingRepository.findBookingByUidWithEventType).toHaveBeenCalledWith({ + bookingUid: "test-booking-uid", + }); + + expect(mockCreateBooking).toHaveBeenCalledWith({ + bookingData: { + eventTypeId: mockBooking.eventTypeId, + start: "2023-12-01T14:00:00.000Z", + end: "2023-12-01T15:00:00.000Z", + timeZone: "UTC", + language: "en", + metadata: {}, + rescheduleUid: mockBooking.uid, + }, + bookingMeta: { + skipCalendarSyncTaskCreation: true, + }, + }); + }); + + test("should use original times when event times are null", async () => { + const eventWithNullTimes: CalendarSubscriptionEventItem = { + ...mockCalComEvent, + start: null, + end: null, + }; + + mockBookingRepository.findBookingByUidWithEventType = vi.fn().mockResolvedValue(mockBooking); + + await service.rescheduleBooking(eventWithNullTimes); + + expect(mockCreateBooking).toHaveBeenCalledWith({ + bookingData: { + eventTypeId: mockBooking.eventTypeId, + start: "2023-12-01T10:00:00.000Z", + end: "2023-12-01T11:00:00.000Z", + timeZone: "UTC", + language: "en", + metadata: {}, + rescheduleUid: mockBooking.uid, + }, + bookingMeta: { + skipCalendarSyncTaskCreation: true, + }, + }); + }); + + test("should return early when booking UID is missing", async () => { + const eventWithoutUID: CalendarSubscriptionEventItem = { + ...mockCalComEvent, + iCalUID: null, + }; + + await service.rescheduleBooking(eventWithoutUID); + + expect(mockBookingRepository.findBookingByUidWithEventType).not.toHaveBeenCalled(); + expect(mockCreateBooking).not.toHaveBeenCalled(); + }); + + test("should return early when booking UID is malformed", async () => { + const eventWithMalformedUID: CalendarSubscriptionEventItem = { + ...mockCalComEvent, + iCalUID: "@cal.com", + }; + + await service.rescheduleBooking(eventWithMalformedUID); + + expect(mockBookingRepository.findBookingByUidWithEventType).not.toHaveBeenCalled(); + expect(mockCreateBooking).not.toHaveBeenCalled(); + }); + + test("should return early when booking is not found", async () => { + mockBookingRepository.findBookingByUidWithEventType = vi.fn().mockResolvedValue(null); + + await service.rescheduleBooking(mockCalComEvent); + + expect(mockBookingRepository.findBookingByUidWithEventType).toHaveBeenCalledWith({ + bookingUid: "test-booking-uid", + }); + expect(mockCreateBooking).not.toHaveBeenCalled(); + }); + + test("should handle rescheduling errors gracefully without throwing", async () => { + mockBookingRepository.findBookingByUidWithEventType = vi.fn().mockResolvedValue(mockBooking); + mockCreateBooking.mockRejectedValue(new Error("Rescheduling failed")); + + // Should not throw - errors are caught and logged + await expect(service.rescheduleBooking(mockCalComEvent)).resolves.not.toThrow(); + + expect(mockBookingRepository.findBookingByUidWithEventType).toHaveBeenCalled(); + expect(mockCreateBooking).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index 9dd03b80d581d3..a5d1e46975e9a3 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -390,6 +390,7 @@ export const bookingCancelSchema = z.object({ cancelSubsequentBookings: z.boolean().optional(), cancellationReason: z.string().optional(), skipCancellationReasonValidation: z.boolean().optional(), + skipCalendarSyncTaskCancellation: z.boolean().optional(), seatReferenceUid: z.string().optional(), cancelledBy: z.string().email({ message: "Invalid email" }).optional(), internalNote: z