Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
267 changes: 267 additions & 0 deletions packages/features/bookings/lib/service/RegularBookingService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
import {
createBookingScenario,
getBooker,
getDate,
getGoogleCalendarCredential,
getOrganizer,
getScenarioData,
mockCalendarToCrashOnCreateEvent,
mockCalendarToHaveNoBusySlots,
mockSuccessfulVideoMeetingCreation,
TestData,
} from "@calcom/testing/lib/bookingScenario/bookingScenario";
import process from "node:process";
import { getRegularBookingService } from "@calcom/features/bookings/di/RegularBookingService.container";
import { BookingStatus } from "@calcom/prisma/enums";
import { expectBookingToBeInDatabase } from "@calcom/testing/lib/bookingScenario/expects";
import { getMockRequestDataForBooking } from "@calcom/testing/lib/bookingScenario/getMockRequestDataForBooking";
import { setupAndTeardown } from "@calcom/testing/lib/bookingScenario/setupAndTeardown";
import { test } from "@calcom/testing/lib/fixtures/fixtures";
import { describe } from "vitest";

const timeout = process.env.CI ? 5000 : 20000;

describe("RegularBookingService", () => {
setupAndTeardown();

describe("Calendar sync failure handling", () => {
test(
`should set booking to PENDING when all calendar event creations fail`,
async () => {
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],
destinationCalendar: {
integration: "google_calendar",
externalId: "organizer@google-calendar.com",
},
});

const plus1DateString = getDate({ dateIncrement: 1 }).dateString;

await createBookingScenario(
getScenarioData({
eventTypes: [
{
id: 1,
slotInterval: 30,
length: 30,
users: [{ id: 101 }],
destinationCalendar: {
integration: "google_calendar",
externalId: "event-type-1@google-calendar.com",
},
},
],
organizer,
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
})
);

mockSuccessfulVideoMeetingCreation({
metadataLookupKey: "dailyvideo",
videoMeetingData: {
id: "MOCK_ID",
password: "MOCK_PASS",
url: `http://mock-dailyvideo.example.com/meeting-1`,
},
});

// Mock calendar to CRASH on event creation — simulating API failure
await mockCalendarToCrashOnCreateEvent("googlecalendar");

const mockBookingData = getMockRequestDataForBooking({
data: {
eventTypeId: 1,
start: `${plus1DateString}T04:00:00.000Z`,
end: `${plus1DateString}T04:30:00.000Z`,
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: "integrations:daily" },
},
},
});

const regularBookingService = getRegularBookingService();
const createdBooking = await regularBookingService.createBooking({
bookingData: mockBookingData,
});

// After fix: booking should be PENDING when calendar sync fails,
// so the user knows the calendar event was not created.
await expectBookingToBeInDatabase({
uid: createdBooking.uid,
status: BookingStatus.PENDING,
});
},
timeout
);

test(
`should create booking successfully when calendar event creation succeeds`,
async () => {
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],
destinationCalendar: {
integration: "google_calendar",
externalId: "organizer@google-calendar.com",
},
});

const plus1DateString = getDate({ dateIncrement: 1 }).dateString;

await createBookingScenario(
getScenarioData({
eventTypes: [
{
id: 1,
slotInterval: 30,
length: 30,
users: [{ id: 101 }],
destinationCalendar: {
integration: "google_calendar",
externalId: "event-type-1@google-calendar.com",
},
},
],
organizer,
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
})
);

mockSuccessfulVideoMeetingCreation({
metadataLookupKey: "dailyvideo",
videoMeetingData: {
id: "MOCK_ID",
password: "MOCK_PASS",
url: `http://mock-dailyvideo.example.com/meeting-1`,
},
});

// Mock calendar to succeed
await mockCalendarToHaveNoBusySlots("googlecalendar", {
create: {
id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID",
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID",
},
});

const mockBookingData = getMockRequestDataForBooking({
data: {
eventTypeId: 1,
start: `${plus1DateString}T04:00:00.000Z`,
end: `${plus1DateString}T04:30:00.000Z`,
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: "integrations:daily" },
},
},
});

const regularBookingService = getRegularBookingService();
const createdBooking = await regularBookingService.createBooking({
bookingData: mockBookingData,
});

// Happy path: booking should be ACCEPTED with calendar references
await expectBookingToBeInDatabase({
uid: createdBooking.uid,
status: BookingStatus.ACCEPTED,
});
},
timeout
);

test(
`should set booking to PENDING when calendar fails and no video integration is configured`,
async () => {
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],
destinationCalendar: {
integration: "google_calendar",
externalId: "organizer@google-calendar.com",
},
});

const plus1DateString = getDate({ dateIncrement: 1 }).dateString;

await createBookingScenario(
getScenarioData({
eventTypes: [
{
id: 1,
slotInterval: 30,
length: 30,
users: [{ id: 101 }],
destinationCalendar: {
integration: "google_calendar",
externalId: "event-type-1@google-calendar.com",
},
},
],
organizer,
apps: [TestData.apps["google-calendar"]],
})
);

// No video mock — calendar-only scenario
await mockCalendarToCrashOnCreateEvent("googlecalendar");

const mockBookingData = getMockRequestDataForBooking({
data: {
eventTypeId: 1,
start: `${plus1DateString}T04:00:00.000Z`,
end: `${plus1DateString}T04:30:00.000Z`,
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: "attendee" },
},
},
});

const regularBookingService = getRegularBookingService();
const createdBooking = await regularBookingService.createBooking({
bookingData: mockBookingData,
});

await expectBookingToBeInDatabase({
uid: createdBooking.uid,
status: BookingStatus.PENDING,
});
},
timeout
);
});
});
45 changes: 34 additions & 11 deletions packages/features/bookings/lib/service/RegularBookingService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2061,6 +2061,12 @@ async function handler(
referencesToCreate = createManager.referencesToCreate;
videoCallUrl = evt.videoCallData?.url ? evt.videoCallData.url : null;

const calendarResults = results.filter((res) => res.type.includes("_calendar"));
// Only downgrade to PENDING when ALL calendar integrations fail — if at least one
// calendar got the event, the organizer still has visibility on the booking.
const allCalendarEventsFailed =
calendarResults.length > 0 && calendarResults.every((res) => !res.success);

if (results.length > 0 && results.every((res) => !res.success)) {
const error = {
errorCode: "BookingCreatingMeetingFailed",
Expand All @@ -2071,7 +2077,22 @@ async function handler(
`EventManager.create failure in some of the integrations ${organizerUser.username}`,
safeStringify({ error, results })
);
} else {
}

if (allCalendarEventsFailed) {
await deps.prismaClient.booking.update({
where: { id: booking.id },
data: { status: BookingStatus.PENDING },
});
Comment on lines +2082 to +2086
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

This still leaves a transient ACCEPTED booking in the DB.

By the time this runs, createBooking() has already persisted the confirmed-by-default booking earlier in the flow, so this is only a compensating update after EventManager.create() returns. Anything observing the row in that gap can still see the phantom ACCEPTED state the issue is trying to remove. Consider persisting these bookings as PENDING first and only promoting them to ACCEPTED after at least one calendar write succeeds.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/features/bookings/lib/service/RegularBookingService.ts` around lines
2082 - 2086, The current flow persists bookings as ACCEPTED before
EventManager.create() runs, causing a transient ACCEPTED state; change the
createBooking flow in RegularBookingService (the code that calls
EventManager.create() and uses deps.prismaClient.booking.update on booking) to
initially persist the booking record with status BookingStatus.PENDING, then
call EventManager.create(); after at least one calendar write succeeds,
explicitly update that booking row to BookingStatus.ACCEPTED (via
deps.prismaClient.booking.update), and only use the compensating rollback to
PENDING if all calendar writes fail. Locate the logic around createBooking(),
the booking variable, EventManager.create(), and the update call to
BookingStatus.PENDING/ACCEPTED and switch the order of operations so promotion
to ACCEPTED happens after successful calendar writes.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged => the transient ACCEPTED window is a known trade-off. Persisting as PENDING first would require changing createBooking() which conditions all downstream side effects (notifications, payments, workflows). That's a much larger refactor with higher regression risk. The compensating update runs before any side effect fires, so no consumer observes the phantom state in practice.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@pmartin-dev, that's a fair and well-reasoned trade-off. Looking at the code flow confirms your claim: fireBookingEvents, the webhook trigger, scheduleNoShowTriggers, and the email dispatch are all gated on booking.status === BookingStatus.ACCEPTED or !allCalendarEventsFailed, and they all execute after the compensating booking.status = BookingStatus.PENDING mutation. So within this handler, no side effects can observe the phantom ACCEPTED state.

The only residual exposure is external DB readers (e.g., a separate process or replica polling the booking table) that happen to query between createBooking() completing and eventManager.create() + the compensating update finishing. That window is genuinely narrow and, for the scope of this fix vs. the regression risk of refactoring createBooking(), the trade-off is defensible.

Happy to close this out.


✏️ Learnings added
Learnt from: pmartin-dev
Repo: calcom/cal.diy PR: 28916
File: packages/features/bookings/lib/service/RegularBookingService.ts:2082-2086
Timestamp: 2026-04-16T13:00:11.708Z
Learning: In `packages/features/bookings/lib/service/RegularBookingService.ts`, when all calendar integrations fail (`allCalendarEventsFailed`), the booking is compensated to `BookingStatus.PENDING` via a `prismaClient.booking.update` call immediately after `eventManager.create()` returns. This is intentionally a compensating-update pattern (not an optimistic-PENDING-then-promote pattern), because `createBooking()` conditions downstream side effects on the initial status; refactoring that is considered too high-regression-risk. All side effects (emails, webhooks, no-show triggers) are gated on `booking.status === ACCEPTED` and fire after the in-memory mutation, so no consumer within the handler observes the transient `ACCEPTED` state. The only residual risk is external DB readers during the narrow window between `createBooking()` and the compensating update — accepted as a known trade-off.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

booking.status = BookingStatus.PENDING;

tracingLogger.error(
`Calendar event creation failed for all calendars, booking ${booking.uid} set to PENDING`,
safeStringify({ results: calendarResults })
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (!results.every((res) => !res.success)) {
const additionalInformation: AdditionalInformation = {};

if (results.length) {
Expand Down Expand Up @@ -2148,7 +2169,7 @@ async function handler(
});
}
}
if (!noEmail) {
if (!noEmail && !allCalendarEventsFailed) {
if (!isDryRun && !(eventType.seatsPerTimeSlot && rescheduleUid)) {
await emailsAndSmsHandler.send({
action: BookingActionMap.confirmed,
Expand Down Expand Up @@ -2245,7 +2266,7 @@ async function handler(
: undefined,
metadata: { ...metadata, ...reqBody.metadata },
eventTypeId,
status: "ACCEPTED",
status: booking?.status === BookingStatus.PENDING ? "PENDING" : "ACCEPTED",
smsReminderNumber: booking?.smsReminderNumber || undefined,
rescheduledBy: reqBody.rescheduledBy,
location: webhookLocation,
Expand Down Expand Up @@ -2430,13 +2451,15 @@ async function handler(
}

// Send Webhook call if hooked to BOOKING_CREATED & BOOKING_RESCHEDULED
await handleWebhookTrigger({
subscriberOptions,
eventTrigger,
webhookData,
isDryRun,
traceContext,
});
if (booking?.status === BookingStatus.ACCEPTED) {
await handleWebhookTrigger({
subscriberOptions,
eventTrigger,
webhookData,
isDryRun,
traceContext,
});
}
}

if (!booking) throw new HttpError({ statusCode: 400, message: "Booking failed" });
Expand Down Expand Up @@ -2492,7 +2515,7 @@ async function handler(
};

try {
if (isConfirmedByDefault) {
if (isConfirmedByDefault && booking.status === BookingStatus.ACCEPTED) {
await scheduleNoShowTriggers({
booking: {
startTime: booking.startTime,
Expand Down
Loading