Skip to content
Draft
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
13 changes: 9 additions & 4 deletions packages/features/bookings/lib/handleCancelBooking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,28 +38,27 @@ import {
} from "@calcom/features/webhooks/lib/scheduleTrigger";
import sendPayload from "@calcom/features/webhooks/lib/sendOrSchedulePayload";
import type { EventTypeInfo } from "@calcom/features/webhooks/lib/sendPayload";
import { getTranslation } from "@calcom/i18n/server";
import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType";
import { HttpError } from "@calcom/lib/http-error";
import { isPrismaObjOrUndefined } from "@calcom/lib/isPrismaObj";
import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { getTranslation } from "@calcom/i18n/server";
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
// TODO: Prisma import would be used from DI in a followup PR when we remove `handler` export
import prisma from "@calcom/prisma";
import type { WebhookTriggerEvents, WorkflowMethods } from "@calcom/prisma/enums";
import { BookingStatus } from "@calcom/prisma/enums";

import { isCancellationReasonRequired } from "./cancellationReason";
import type { EventTypeMetadata } from "@calcom/prisma/zod-utils";
import { bookingCancelInput, bookingMetadataSchema } from "@calcom/prisma/zod-utils";
import type { CalendarEvent } from "@calcom/types/Calendar";
import { v4 as uuidv4 } from "uuid";
import type { z } from "zod";
import { BookingRepository } from "../repositories/BookingRepository";
import { PrismaBookingAttendeeRepository } from "../repositories/PrismaBookingAttendeeRepository";
import { isCancellationReasonRequired } from "./cancellationReason";
import type {
CancelBookingMeta,
CancelRegularBookingData,
Expand Down Expand Up @@ -224,7 +223,12 @@ async function handler(input: CancelBookingInput, dependencies?: Dependencies) {
isCancellationUserHost
);

if (!platformClientId && !cancellationReason?.trim() && isReasonRequired && !skipCancellationReasonValidation) {
if (
!platformClientId &&
!cancellationReason?.trim() &&
isReasonRequired &&
!skipCancellationReasonValidation
) {
throw new HttpError({
statusCode: 400,
message: "Cancellation reason is required",
Expand Down Expand Up @@ -456,6 +460,7 @@ async function handler(input: CancelBookingInput, dependencies?: Dependencies) {
smsReminderNumber: bookingToDelete.smsReminderNumber || undefined,
cancelledBy: cancelledBy,
requestReschedule: false,
rescheduleRequested: false,
}).catch((e) => {
logger.error(
`Error executing webhook for event: ${eventTrigger}, URL: ${webhook.subscriberUrl}, bookingId: ${evt.bookingId}, bookingUid: ${evt.uid}`,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import {
BookingLocations,
createBookingScenario,
getBooker,
getDate,
getGoogleCalendarCredential,
getOrganizer,
getScenarioData,
mockCalendarToHaveNoBusySlots,
mockSuccessfulVideoMeetingCreation,
TestData,
} from "@calcom/testing/lib/bookingScenario/bookingScenario";
import { BookingWebhookFactory } from "@calcom/lib/server/service/BookingWebhookFactory";
import { BookingStatus } from "@calcom/prisma/enums";
import { expectBookingCancelledWebhookToHaveBeenFired } from "@calcom/testing/lib/bookingScenario/expects";
import { setupAndTeardown } from "@calcom/testing/lib/bookingScenario/setupAndTeardown";
import { test } from "@calcom/testing/lib/fixtures/fixtures";
import { describe } from "vitest";

/**
* Verifies the rescheduleRequested field in BOOKING_CANCELLED webhook payloads.
*
* - Genuine cancellations (handleCancelBooking): rescheduleRequested must be false.
* - Host "Request Reschedule" action (requestReschedule.handler): rescheduleRequested
* must be true. This path sets rescheduleRequested directly on the payload object
* after calling BookingWebhookFactory.createCancelledEventPayload, so we test the
* factory output + field assignment as a unit, then the full handler path via the
* payload structure test.
*
* See: packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts
*/
describe("rescheduleRequested field in BOOKING_CANCELLED webhook payload", () => {
setupAndTeardown();

test("genuine cancellation: BOOKING_CANCELLED webhook payload has rescheduleRequested: false", async () => {
const handleCancelBooking = (await import("@calcom/features/bookings/lib/handleCancelBooking")).default;

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 uidOfBookingToBeCancelled = "h5Wv3eHgconAED2rescheduleTest";
const idOfBookingToBeCancelled = 2001;
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });

await createBookingScenario(
getScenarioData({
webhooks: [
{
userId: organizer.id,
eventTriggers: ["BOOKING_CANCELLED"],
subscriberUrl: "http://reschedule-test-webhook.example.com",
active: true,
eventTypeId: 1,
appId: null,
},
],
eventTypes: [
{
id: 1,
slotInterval: 30,
length: 30,
users: [
{
id: 101,
},
],
},
],
bookings: [
{
id: idOfBookingToBeCancelled,
uid: uidOfBookingToBeCancelled,
attendees: [
{
email: booker.email,
timeZone: "Asia/Kolkata",
},
],
eventTypeId: 1,
userId: 101,
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: BookingLocations.CalVideo },
},
status: BookingStatus.ACCEPTED,
startTime: `${plus1DateString}T05:00:00.000Z`,
endTime: `${plus1DateString}T05:15:00.000Z`,
metadata: {
videoCallUrl: "https://existing-daily-video-call-url.example.com",
},
},
],
organizer,
apps: [TestData.apps["daily-video"]],
})
);

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

mockCalendarToHaveNoBusySlots("googlecalendar", {
create: { id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID" },
});

await handleCancelBooking({
bookingData: {
id: idOfBookingToBeCancelled,
uid: uidOfBookingToBeCancelled,
cancelledBy: organizer.email,
cancellationReason: "No longer needed",
},
impersonatedByUserUuid: null,
actionSource: "WEBAPP",
});

expectBookingCancelledWebhookToHaveBeenFired({
booker,
organizer,
location: BookingLocations.CalVideo,
subscriberUrl: "http://reschedule-test-webhook.example.com",
payload: {
// Genuine cancellations must NOT look like host reschedule requests.
rescheduleRequested: false,
requestReschedule: false,
cancelledBy: organizer.email,
},
});
});

test("host reschedule request: BookingWebhookFactory payload combined with rescheduleRequested: true", () => {
// Unit-level verification: the requestReschedule.handler.ts spreads the factory
// payload and appends rescheduleRequested: true. We verify the factory produces the
// correct base and that the field is correctly added.
const factory = new BookingWebhookFactory();
const basePayload = factory.createCancelledEventPayload({
bookingId: 1,
title: "Test Meeting",
eventSlug: "test-event",
description: null,
customInputs: null,
responses: {},
userFieldsResponses: {},
startTime: "2025-01-01T10:00:00Z",
endTime: "2025-01-01T10:30:00Z",
organizer: {
id: 1,
email: "organizer@example.com",
name: "Organizer",
timeZone: "UTC",
language: { locale: "en" },
},
attendees: [],
uid: "test-booking-uid",
location: null,
destinationCalendar: null,
cancellationReason: "Please reschedule. Conflict with another meeting",
iCalUID: null,
cancelledBy: "organizer@example.com",
requestReschedule: true,
eventTypeId: 1,
length: 30,
iCalSequence: 1,
eventTitle: "Test Event",
});

// Simulate what requestReschedule.handler.ts does: spread base payload and add the
// new distinguishing field.
const payload = { ...basePayload, rescheduleRequested: true };

expect(payload.rescheduleRequested).toBe(true);
// requestReschedule must also remain true (backward compat).
expect(payload.requestReschedule).toBe(true);
expect(payload.status).toBe("CANCELLED");
expect(payload.cancelledBy).toBe("organizer@example.com");
});
});
1 change: 1 addition & 0 deletions packages/features/webhooks/lib/sendPayload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ export type EventPayloadType = Omit<CalendarEvent, "assignmentReason"> &
cancelledBy?: string;
paymentData?: PaymentData;
requestReschedule?: boolean;
rescheduleRequested?: boolean;
assignmentReason?: string | { reasonEnum: string; reasonString: string }[] | null;
};

Expand Down
Original file line number Diff line number Diff line change
@@ -1,43 +1,42 @@
import type { TFunction } from "i18next";

import { getCalendar } from "@calcom/app-store/_utils/getCalendar";
import { getDelegationCredentialOrRegularCredential } from "@calcom/app-store/delegationCredential";
import { getUsersCredentialsIncludeServiceAccountKey } from "@calcom/app-store/delegationCredential";
import {
getDelegationCredentialOrRegularCredential,
getUsersCredentialsIncludeServiceAccountKey,
} from "@calcom/app-store/delegationCredential";
import dayjs from "@calcom/dayjs";
import { sendRequestRescheduleEmailAndSMS } from "@calcom/emails/email-manager";
import { makeUserActor } from "@calcom/features/booking-audit/lib/makeActor";
import type { ValidActionSource } from "@calcom/features/booking-audit/lib/types/actionSource";
import { getBookingEventHandlerService } from "@calcom/features/bookings/di/BookingEventHandlerService.container";
import { getFeaturesRepository } from "@calcom/features/di/containers/FeaturesRepository";
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
import { deleteMeeting } from "@calcom/features/conferencing/lib/videoClient";
import { getFeaturesRepository } from "@calcom/features/di/containers/FeaturesRepository";
import { getBookerBaseUrl } from "@calcom/features/ee/organizations/lib/getBookerUrlServer";
import { WorkflowRepository } from "@calcom/features/ee/workflows/repositories/WorkflowRepository";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
import {
deleteWebhookScheduledTriggers,
cancelNoShowTasksForBooking,
deleteWebhookScheduledTriggers,
} from "@calcom/features/webhooks/lib/scheduleTrigger";
import sendPayload from "@calcom/features/webhooks/lib/sendOrSchedulePayload";
import { getTranslation } from "@calcom/i18n/server";
import { CalendarEventBuilder } from "@calcom/lib/builders/CalendarEvent/builder";
import { CalendarEventDirector } from "@calcom/lib/builders/CalendarEvent/director";
import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { getTranslation } from "@calcom/i18n/server";
import { BookingWebhookFactory } from "@calcom/lib/server/service/BookingWebhookFactory";
import { prisma } from "@calcom/prisma";
import type { BookingReference, EventType } from "@calcom/prisma/client";
import { BookingStatus } from "@calcom/prisma/enums";
import type { WebhookTriggerEvents } from "@calcom/prisma/enums";
import { BookingStatus } from "@calcom/prisma/enums";
import type { EventTypeMetadata } from "@calcom/prisma/zod-utils";
import type { Person } from "@calcom/types/Calendar";

import { TRPCError } from "@trpc/server";

import type { TFunction } from "i18next";
import type { TrpcSessionUser } from "../../../types";
import type { TRequestRescheduleInputSchema } from "./requestReschedule.schema";
import type { PersonAttendeeCommonFields } from "./types";
Expand All @@ -51,7 +50,12 @@ type RequestRescheduleOptions = {
impersonatedByUserUuid: string | null;
};
const log = logger.getSubLogger({ prefix: ["requestRescheduleHandler"] });
export const requestRescheduleHandler = async ({ ctx, input, source, impersonatedByUserUuid }: RequestRescheduleOptions) => {
export const requestRescheduleHandler = async ({
ctx,
input,
source,
impersonatedByUserUuid,
}: RequestRescheduleOptions) => {
const { user } = ctx;
const { bookingUid, rescheduleReason: cancellationReason } = input;
log.debug("Started", safeStringify({ bookingUid }));
Expand Down Expand Up @@ -250,7 +254,7 @@ export const requestRescheduleHandler = async ({ ctx, input, source, impersonate
});

const webhookFactory = new BookingWebhookFactory();
const payload = webhookFactory.createCancelledEventPayload({
const basePayload = webhookFactory.createCancelledEventPayload({
bookingId: bookingToReschedule.id,
title: bookingToReschedule.title,
eventSlug: event.slug ?? null,
Expand Down Expand Up @@ -281,6 +285,10 @@ export const requestRescheduleHandler = async ({ ctx, input, source, impersonate
eventTitle: bookingToReschedule.eventType?.title ?? null,
requestReschedule: true,
});
// Signal to webhook consumers that this cancellation is a host-initiated reschedule
// request, not a permanent cancellation — rescheduledToUid is null at this point
// because the attendee hasn't booked the new slot yet.
const payload = { ...basePayload, rescheduleRequested: true };

// Send webhook
const eventTrigger: WebhookTriggerEvents = "BOOKING_CANCELLED";
Expand Down
Loading