Files
5242e419d9 feat: Calendar Sync (#24124)
* feat: calendar cache and sync - wip

* Add env.example

* refactor on CalendarCacheEventService

* remove test console.log

* Fix type checks errors

* chore: remove pt comment

* add route.ts

* chore: fix tests

* Improve cache impl

* chore: update recurring event id

* chore: small improvements

* calendar cache improvements

* Fix remove dynamic imports

* Add cleanup stale cache

* Fix tests

* add event update

* type fixes

* feat: add comprehensive tests for new calendar subscription API routes

- Add tests for /api/cron/calendar-subscriptions-cleanup route (9 tests)
- Add tests for /api/cron/calendar-subscriptions route (10 tests)
- Add tests for /api/webhooks/calendar-subscription/[provider] route (11 tests)
- Add missing feature flags for calendar-subscription-cache and calendar-subscription-sync
- All 30 tests pass with comprehensive coverage of authentication, feature flags, error handling, and service instantiation

Tests cover:
- Authentication scenarios (API key validation, Bearer tokens, query parameters)
- Feature flag combinations (cache/sync enabled/disabled states)
- Success and error handling (including non-Error exceptions)
- Service instantiation with proper dependency injection
- Provider validation for webhook endpoints

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

* feat: add comprehensive tests for calendar subscription services, repositories, and adapters

- Add unit tests for CalendarSubscriptionService with subscription, webhook, and event processing
- Add unit tests for CalendarCacheEventService with cache operations and cleanup
- Add unit tests for CalendarSyncService with Cal.com event filtering and booking operations
- Add unit tests for CalendarCacheEventRepository with CRUD operations
- Add unit tests for SelectedCalendarRepository with calendar selection management
- Add unit tests for GoogleCalendarSubscriptionAdapter with subscription and event fetching
- Add unit tests for Office365CalendarSubscriptionAdapter with placeholder implementation
- Add unit tests for AdaptersFactory with provider management and adapter creation
- Fix lint issues by removing explicit 'any' type casting and unused variables
- All tests follow Cal.com conventions using Vitest framework with proper mocking

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

* fix: improve calendar-subscriptions-cleanup test performance by adding missing mocks

- Add comprehensive mocks for defaultResponderForAppDir, logger, performance monitoring, and Sentry
- Fix slow test execution (933ms -> <100ms) caused by missing dependency mocks
- Ensure consistent test performance across different environments

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

* Fix tests

* Fix tests

* type fix

* Fix coderabbit comments

* Fix types

* Fix test

* Update apps/web/app/api/cron/calendar-subscriptions/route.ts

Co-authored-by: Alex van Andel <me@alexvanandel.com>

* Fixes by first review

* feat: add database migrations for calendar cache and sync fields

- Add CalendarCacheEventStatus enum with confirmed, tentative, cancelled values
- Add new fields to SelectedCalendar: channelId, channelKind, channelResourceId, channelResourceUri, channelExpiration, syncSubscribedAt, syncToken, syncedAt, syncErrorAt, syncErrorCount
- Create CalendarCacheEvent table with foreign key to SelectedCalendar
- Add necessary indexes and constraints for performance and data integrity

Fixes database schema issues causing e2e test failures with 'column does not exist' errors.

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

* only google-calendar for now

* docs: add Calendar Cache and Sync feature documentation

- Add comprehensive feature overview and motivation
- Document feature flags with SQL examples
- Include SQL examples for enabling features for users and teams
- Reference technical documentation files

Addresses PR #23876 documentation requirements

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

* docs: update calendar subscription README with comprehensive documentation

- Undo incorrect changes to main README.md
- Update packages/features/calendar-subscription/README.md with:
  - Feature overview and motivation
  - Environment variables section
  - Complete feature flags documentation with SQL examples
  - SQL examples for enabling features for users and teams
  - Detailed architecture documentation

Addresses PR #23876 documentation requirements

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

* fix docs

* Fix test to available calendars

* Fix test to available calendars

* add migration and sync boilerplate

* fix typo

* remove double log

* sync boilerplate

* remove console.log

* only subscribe for google calendar

* adjust for 3 months fetch

* only subscribe for teams that have feature enabled

* adjust tests

* chore: safe increment error count

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* calendar sync

* test: add comprehensive tests for CalendarSyncService

- Add comprehensive test coverage for CalendarSyncService methods
- Test handleEvents, cancelBooking, and rescheduleBooking functionality
- Cover edge cases like missing UIDs, malformed UIDs, and error handling
- Fix dynamic import usage in CalendarSyncService to use .default
- Remove invalid properties from handleNewBooking call to fix type errors

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

* fix: add skipCalendarSyncTaskCreation flag to handleCancelBooking to prevent infinite loops

- Add skipCalendarSyncTaskCreation field to bookingCancelSchema in zod-utils.ts
- Update handleCancelBooking to skip EventManager.cancelEvent when flag is true
- Update CalendarSyncService.cancelBooking to pass skipCalendarSyncTaskCreation: true
- Update CalendarSyncService.rescheduleBooking to pass skipCalendarSyncTaskCreation: true
- Update tests to verify the flags are passed correctly

This prevents infinite loops when calendar events are cancelled/rescheduled from
external calendars (Google/Office365) which trigger webhooks to Cal.com, which
would otherwise try to update the external calendar again.

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

* refactor: rename flag to skipCalendarSyncTaskCancellation and use static imports

- Rename skipCalendarSyncTaskCreation to skipCalendarSyncTaskCancellation in handleCancelBooking
- Convert dynamic imports to static imports in CalendarSyncService
- Update tests to use vi.hoisted for proper mock hoisting with static imports

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

* feat: add error handling and static import for handleNewBooking

- Create index.ts entry point for handleNewBooking directory
- Add try-catch error handling to cancelBooking and rescheduleBooking
- Log errors but don't block calendar sync operations
- Update tests to verify errors are caught and logged, not thrown

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

* refactor: use RegularBookingService directly and fix safeStringify usage

- Remove handleNewBooking/index.ts and call getRegularBookingService().createBooking() directly in CalendarSyncService
- Fix safeStringify usage: apply to error itself, not wrapper object
- Update tests to mock getRegularBookingService instead of handleNewBooking

Addresses PR comments from Volnei and Cubic-dev-ai

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

* fix: restore handleNewBooking/index.ts and fix bookingData structure for calendar sync

- Restore handleNewBooking/index.ts as entry point for static imports
- Fix CalendarSyncService.rescheduleBooking to use correct bookingData structure
  with required fields (eventTypeId, start, end, timeZone, language, metadata)
- Use rescheduleUid to indicate this is a reschedule operation
- Fix safeStringify usage in error logging (wrap only the error, not the whole object)
- Update tests to match new bookingData structure

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

* refactor: address PR comments - use RegularBookingService directly, remove unnecessary flags, fix booking reference update

- Remove handleNewBooking/index.ts wrapper and use getRegularBookingService().createBooking() directly in CalendarSyncService
- Remove allRemainingBookings and cancelSubsequentBookings flags from cancelBooking (only cancel the specific booking, not the entire series)
- Move bookingReference update outside skipCalendarSyncTaskCancellation block for data consistency
- Update tests to match new implementation

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

* chore: remove unnecessary comment from zod-utils.ts

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

* feat: add Sentry metrics telemetry and fix null assertions in CalendarSyncService

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

* fix: use dynamic import for getRegularBookingService to avoid RAQB import in server context

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

* fix: use dynamic import for findTeamMembersMatchingAttributeLogic to avoid RAQB import in server context

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

* improve booking update

* add more tests and edge cases

* fix: add required actionSource and Sentry mocks to calendar sync

Add actionSource: "SYSTEM" to handleCancelBooking call after it became
required, and mock @sentry/nextjs in test files.

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-02-26 13:12:52 -03:00

359 lines
12 KiB
TypeScript

import type { CreateRegularBookingData } from "@calcom/features/bookings/lib/dto/types";
import { IdempotencyKeyService } from "@calcom/lib/idempotencyKey/idempotencyKeyService";
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"] });
/**
* Service to handle synchronization of calendar events.
*/
export class CalendarSyncService {
constructor(
private deps: {
bookingRepository: BookingRepository;
}
) {}
/**
* Handles synchronization of calendar events
*
* @param selectedCalendar calendar to process
* @param calendarSubscriptionEvents events to process
* @returns
*/
async handleEvents(
selectedCalendar: SelectedCalendar,
calendarSubscriptionEvents: CalendarSubscriptionEventItem[]
) {
log.debug("handleEvents", {
externalId: selectedCalendar.externalId,
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;
}
log.debug("handleEvents: processing calendar events", { count: calEvents.length });
await Promise.all(
calEvents.map((e) => {
if (e.status === "cancelled") {
return this.cancelBooking(e, selectedCalendar.userId);
} else {
return this.rescheduleBooking(e, selectedCalendar.userId);
}
})
);
}
/**
* Cancels a booking
* @param event
* @returns
*/
async cancelBooking(event: CalendarSubscriptionEventItem, calendarUserId: number) {
const startTime = performance.now();
log.debug("cancelBooking", { event });
const [bookingUid] = event.iCalUID?.split("@") ?? [undefined];
if (!bookingUid) {
log.debug("Unable to sync, booking UID not found in iCalUID");
return;
}
try {
const booking = await this.deps.bookingRepository.findBookingByUidWithEventType({ bookingUid });
if (!booking) {
log.debug("Unable to sync, booking not found in database", { bookingUid });
return;
}
if (booking.userId !== calendarUserId) {
log.debug("Skipping sync, calendar owner is not the booking host", {
bookingUid,
calendarUserId,
bookingUserId: booking.userId,
});
metrics.count("calendar.sync.cancelBooking.calls", 1, {
attributes: { status: "skipped", reason: "not_booking_host" },
});
return;
}
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;
}
await handleCancelBooking({
userId: booking.userId,
actionSource: "SYSTEM",
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("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" },
});
}
}
/**
* Reschedule a booking
* @param event
*/
async rescheduleBooking(event: CalendarSubscriptionEventItem, calendarUserId: number) {
const startTime = performance.now();
log.debug("rescheduleBooking", { event });
const [bookingUid] = event.iCalUID?.split("@") ?? [undefined];
if (!bookingUid) {
log.debug("Unable to sync, booking UID not found in iCalUID");
return;
}
try {
const booking = await this.deps.bookingRepository.findBookingByUidWithEventType({ bookingUid });
if (!booking) {
log.debug("Unable to sync, booking not found in database", { bookingUid });
return;
}
if (booking.userId !== calendarUserId) {
log.debug("Skipping sync, calendar owner is not the booking host", {
bookingUid,
calendarUserId,
bookingUserId: booking.userId,
});
metrics.count("calendar.sync.rescheduleBooking.calls", 1, {
attributes: { status: "skipped", reason: "not_booking_host" },
});
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;
}
if (!hasStartTimeChanged(booking, event)) {
log.debug("Skipping reschedule, start time has not changed", { bookingUid });
metrics.count("calendar.sync.rescheduleBooking.calls", 1, {
attributes: { status: "skipped", reason: "no_time_change" },
});
return;
}
// 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: buildRescheduleBookingData(booking, event),
bookingMeta: {
// Skip calendar event creation to avoid infinite loops
// (Google/Office365 → Cal.com → Google/Office365 → ...)
skipCalendarSyncTaskCreation: true,
skipAvailabilityCheck: true,
skipEventLimitsCheck: 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("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" },
});
}
}
}
export type BookingWithEventType = NonNullable<
Awaited<ReturnType<BookingRepository["findBookingByUidWithEventType"]>>
>;
type RescheduleBookingData = CreateRegularBookingData & {
responses: Record<string, unknown>;
idempotencyKey: string;
};
export const buildRescheduleBookingData = (
booking: BookingWithEventType,
event: CalendarSubscriptionEventItem
): RescheduleBookingData => {
const fallbackStart = booking.startTime.toISOString();
const start = event.start?.toISOString() ?? fallbackStart;
// Keep the original booking duration — external calendar controls "when", Cal.com controls "how long"
const originalDurationMs = booking.endTime.getTime() - booking.startTime.getTime();
const end = new Date(new Date(start).getTime() + originalDurationMs).toISOString();
return {
eventTypeId: booking.eventTypeId!,
start,
end,
timeZone: event.timeZone ?? "UTC",
language: "en",
metadata: buildMetadataFromCalendarEvent(event),
rescheduleUid: booking.uid,
idempotencyKey: IdempotencyKeyService.generate({
startTime: new Date(start),
endTime: new Date(end),
userId: booking.userId ?? undefined,
reassignedById: null,
}),
responses: mergeBookingResponsesWithEventData(booking, event),
};
};
export const extractBookingResponses = (booking: BookingWithEventType): Record<string, unknown> => {
const rawResponses = booking.responses;
if (rawResponses && typeof rawResponses === "object" && !Array.isArray(rawResponses)) {
return rawResponses as Record<string, unknown>;
}
const fallbackLocation = booking.location ?? "";
return {
name: booking.title,
email: booking.userPrimaryEmail ?? "",
guests: [],
notes: booking.description ?? "",
smsReminderNumber: booking.smsReminderNumber ?? undefined,
location: fallbackLocation
? {
label: fallbackLocation,
value: fallbackLocation,
optionValue: fallbackLocation,
}
: undefined,
};
};
export const mergeBookingResponsesWithEventData = (
booking: BookingWithEventType,
event: CalendarSubscriptionEventItem
): Record<string, unknown> => {
const baseResponses = extractBookingResponses(booking);
const overrides: Record<string, unknown> = {};
if (event.summary) {
overrides.title = event.summary;
}
if (event.description) {
overrides.notes = event.description;
}
if (event.location) {
overrides.location = {
value: event.location,
label: event.location,
optionValue: event.location,
};
}
return {
...baseResponses,
...overrides,
};
};
export const buildMetadataFromCalendarEvent = (event: CalendarSubscriptionEventItem) => {
const payload = {
summary: event.summary ?? null,
description: event.description ?? null,
location: event.location ?? null,
busy: event.busy ?? null,
status: event.status ?? null,
isAllDay: event.isAllDay ?? null,
timeZone: event.timeZone ?? null,
recurringEventId: event.recurringEventId ?? null,
originalStartDate: event.originalStartDate?.toISOString() ?? null,
createdAt: event.createdAt?.toISOString() ?? null,
updatedAt: event.updatedAt?.toISOString() ?? null,
etag: event.etag ?? null,
kind: event.kind ?? null,
};
return {
calendarSubscriptionEvent: JSON.stringify(payload),
};
};
export const hasStartTimeChanged = (
booking: BookingWithEventType,
event: CalendarSubscriptionEventItem
): boolean => {
if (!event.start) return false;
return event.start.getTime() !== booking.startTime.getTime();
};