feat: Configure cancellation reason (#26872)

* feat: Configure cancellation reason

* fix: use enums

* tests: add unit tests

* fix: type error

* chore: remove duplicate dialog

* fix: type erro

* refator: improvements

* refator: improvements

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Udit Takkar
2026-02-24 22:05:51 +05:30
committed by GitHub
parent c9abc556ba
commit e3a9f54ba5
24 changed files with 1009 additions and 51 deletions
@@ -1053,7 +1053,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
.set("Authorization", `Bearer ${apiKeyString}`)
.expect(400);
expect(response.body.message).toEqual("Cancellation reason is required when you are the host");
expect(response.body.message).toEqual("Cancellation reason is required");
});
it("should cancel seated booking", async () => {
+17 -8
View File
@@ -1,17 +1,18 @@
"use client";
import { useCallback, useState } from "react";
import { sdkActionManager } from "@calcom/embed-core/embed-iframe";
import { isCancellationReasonRequired } from "@calcom/features/bookings/lib/cancellationReason";
import { shouldChargeNoShowCancellationFee } from "@calcom/features/bookings/lib/payment/shouldChargeNoShowCancellationFee";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useRefreshData } from "@calcom/lib/hooks/useRefreshData";
import type { CancellationReasonRequirement } from "@calcom/prisma/enums";
import type { RecurringEvent } from "@calcom/types/Calendar";
import classNames from "@calcom/ui/classNames";
import { Button } from "@calcom/ui/components/button";
import { Label, Select, TextArea, CheckboxField } from "@calcom/ui/components/form";
import { CheckboxField, Label, Select, TextArea } from "@calcom/ui/components/form";
import { showToast } from "@calcom/ui/components/toast";
import { InfoIcon, XIcon } from "@coss/ui/icons";
import { useCallback, useState } from "react";
interface InternalNotePresetsSelectProps {
internalNotePresets: { id: number; name: string }[];
@@ -110,6 +111,7 @@ type Props = {
internalNotePresets: { id: number; name: string; cancellationReason: string | null }[];
renderContext: "booking-single-view" | "dialog";
eventTypeMetadata?: Record<string, unknown> | null;
requiresCancellationReason?: CancellationReasonRequirement | null;
showErrorAsToast?: boolean;
onCanceled?: () => void;
};
@@ -163,11 +165,18 @@ export default function CancelBooking(props: Props) {
const isCancellationUserHost =
props.isHost || bookingCancelledEventProps.organizer.email === currentUserEmail;
const hostMissingCancellationReason =
isCancellationUserHost &&
(!cancellationReason?.trim() || (props.internalNotePresets.length > 0 && !internalNote?.id));
const isReasonRequired = isCancellationReasonRequired(
props.requiresCancellationReason,
isCancellationUserHost
);
const missingRequiredReason = isReasonRequired && !cancellationReason?.trim();
const hostMissingInternalNote =
isCancellationUserHost && props.internalNotePresets.length > 0 && !internalNote?.id;
const cancellationNoShowFeeNotAcknowledged =
!props.isHost && cancellationNoShowFeeWarning && !acknowledgeCancellationNoShowFee;
const canCancel =
!missingRequiredReason && !hostMissingInternalNote && !cancellationNoShowFeeNotAcknowledged;
const cancelBookingRef = useCallback((node: HTMLTextAreaElement) => {
if (node !== null) {
// eslint-disable-next-line @calcom/eslint/no-scroll-into-view-embed -- CancelBooking is not usually used in embed mode
@@ -275,7 +284,7 @@ export default function CancelBooking(props: Props) {
</>
)}
<Label>{isCancellationUserHost ? t("cancellation_reason_host") : t("cancellation_reason")}</Label>
<Label>{t(isReasonRequired ? "cancellation_reason" : "cancellation_reason_optional_label")}</Label>
<TextArea
data-testid="cancel_reason"
@@ -325,7 +334,7 @@ export default function CancelBooking(props: Props) {
</Button>
<Button
data-testid="confirm_cancel"
disabled={hostMissingCancellationReason || cancellationNoShowFeeNotAcknowledged}
disabled={!canCancel}
onClick={handleCancel}
loading={loading}>
{props.allRemainingBookings ? t("cancel_all_remaining") : t("cancel_event")}
@@ -2,6 +2,7 @@ import type { Dispatch, SetStateAction } from "react";
import { Dialog } from "@calcom/features/components/controlled-dialog";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { CancellationReasonRequirement } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import type { RecurringEvent } from "@calcom/types/Calendar";
import { DialogContent, DialogHeader } from "@calcom/ui/components/dialog";
@@ -48,6 +49,7 @@ interface ICancelBookingDialog {
isHost: boolean;
internalNotePresets?: { id: number; name: string; cancellationReason: string | null }[];
eventTypeMetadata?: Record<string, unknown> | null;
requiresCancellationReason?: CancellationReasonRequirement | null;
}
export const CancelBookingDialog = (props: ICancelBookingDialog) => {
@@ -67,6 +69,7 @@ export const CancelBookingDialog = (props: ICancelBookingDialog) => {
isHost,
internalNotePresets = [],
eventTypeMetadata,
requiresCancellationReason,
} = props;
const utils = trpc.useUtils();
@@ -113,6 +116,7 @@ export const CancelBookingDialog = (props: ICancelBookingDialog) => {
isHost={isHost}
internalNotePresets={internalNotePresets}
eventTypeMetadata={eventTypeMetadata}
requiresCancellationReason={requiresCancellationReason}
showErrorAsToast={true}
onCanceled={handleCanceled}
/>
+1
View File
@@ -44,6 +44,7 @@ export const getEventTypesFromDB = async (id: number) => {
hideOrganizerEmail: true,
disableCancelling: true,
disableRescheduling: true,
requiresCancellationReason: true,
minimumRescheduleNotice: true,
disableGuests: true,
timeZone: true,
@@ -938,6 +938,7 @@ export default function Success(props: PageProps) {
payment: props.paymentStatus,
}}
eventTypeMetadata={eventType.metadata}
requiresCancellationReason={eventType.requiresCancellationReason}
profile={{ name: props.profile.name, slug: props.profile.slug }}
recurringEvent={eventType.recurringEvent}
team={eventType?.team?.name}
@@ -1,16 +1,6 @@
import { useState, Suspense, useMemo, useEffect } from "react";
import type { Dispatch, SetStateAction } from "react";
import { Controller, useFormContext } from "react-hook-form";
import type { z } from "zod";
import { getPaymentAppData } from "@calcom/app-store/_utils/payments/getPaymentAppData";
import { useAtomsContext } from "@calcom/atoms/hooks/useAtomsContext";
import { useIsPlatform } from "@calcom/atoms/hooks/useIsPlatform";
import {
SelectedCalendarsSettingsWebWrapper,
SelectedCalendarSettingsScope,
SelectedCalendarsSettingsWebWrapperSkeleton,
} from "@calcom/web/modules/calendars/components/SelectedCalendarsSettingsWebWrapper";
import { Timezone as PlatformTimzoneSelect } from "@calcom/atoms/timezone";
import getLocationsOptionsForSelect from "@calcom/features/bookings/lib/getLocationOptionsForSelect";
import DestinationCalendarSelector from "@calcom/features/calendars/components/DestinationCalendarSelector";
@@ -20,24 +10,20 @@ import {
allowDisablingAttendeeConfirmationEmails,
allowDisablingHostConfirmationEmails,
} from "@calcom/features/ee/workflows/lib/allowDisablingStandardEmails";
import { MultiplePrivateLinksController } from "@calcom/web/modules/event-types/components";
import AddVerifiedEmail from "@calcom/web/modules/event-types/components/AddVerifiedEmail";
import { LearnMoreLink } from "@calcom/features/eventtypes/components/LearnMoreLink";
import type { EventNameObjectType } from "@calcom/features/eventtypes/lib/eventNaming";
import { getEventName } from "@calcom/features/eventtypes/lib/eventNaming";
import type {
FormValues,
EventTypeSetupProps,
SelectClassNames,
CheckboxClassNames,
EventTypeSetupProps,
FormValues,
InputClassNames,
SelectClassNames,
SettingsToggleClassNames,
} from "@calcom/features/eventtypes/lib/types";
import { FormBuilder } from "./FormBuilder";
import { BookerLayoutSelector } from "@calcom/web/modules/settings/components/BookerLayoutSelector";
import {
DEFAULT_LIGHT_BRAND_COLOR,
DEFAULT_DARK_BRAND_COLOR,
DEFAULT_LIGHT_BRAND_COLOR,
MAX_SEATS_PER_TIME_SLOT,
} from "@calcom/lib/constants";
import { generateHashedLink } from "@calcom/lib/generateHashedLink";
@@ -45,25 +31,36 @@ import { checkWCAGContrastColor } from "@calcom/lib/getBrandColours";
import { extractHostTimezone } from "@calcom/lib/hashedLinksUtils";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { Prisma } from "@calcom/prisma/client";
import { SchedulingType } from "@calcom/prisma/enums";
import type { EditableSchema } from "@calcom/prisma/zod-utils";
import type { fieldSchema } from "@calcom/prisma/zod-utils";
import { CancellationReasonRequirement, SchedulingType } from "@calcom/prisma/enums";
import type { EditableSchema, fieldSchema } from "@calcom/prisma/zod-utils";
import type { RouterOutputs } from "@calcom/trpc/react";
import classNames from "@calcom/ui/classNames";
import { Alert } from "@calcom/ui/components/alert";
import { Badge } from "@calcom/ui/components/badge";
import { Button } from "@calcom/ui/components/button";
import {
SelectField,
ColorPicker,
TextField,
Label,
CheckboxField,
Switch,
SettingsToggle,
ColorPicker,
Label,
Select,
SelectField,
SettingsToggle,
Switch,
TextField,
} from "@calcom/ui/components/form";
import { InfoIcon, PencilIcon } from "@coss/ui/icons";
import {
SelectedCalendarSettingsScope,
SelectedCalendarsSettingsWebWrapper,
SelectedCalendarsSettingsWebWrapperSkeleton,
} from "@calcom/web/modules/calendars/components/SelectedCalendarsSettingsWebWrapper";
import { MultiplePrivateLinksController } from "@calcom/web/modules/event-types/components";
import AddVerifiedEmail from "@calcom/web/modules/event-types/components/AddVerifiedEmail";
import { LearnMoreLink } from "@calcom/features/eventtypes/components/LearnMoreLink";
import type { Dispatch, SetStateAction } from "react";
import { Suspense, useEffect, useMemo, useState } from "react";
import { Controller, useFormContext } from "react-hook-form";
import type { z } from "zod";
import type { CustomEventTypeModalClassNames } from "./CustomEventTypeModal";
import CustomEventTypeModal from "./CustomEventTypeModal";
@@ -71,6 +68,7 @@ import type { EmailNotificationToggleCustomClassNames } from "./DisableAllEmails
import { DisableAllEmailsSetting } from "./DisableAllEmailsSetting";
import type { DisableReschedulingCustomClassNames } from "./DisableReschedulingController";
import DisableReschedulingController from "./DisableReschedulingController";
import { FormBuilder } from "./FormBuilder";
import type { RequiresConfirmationCustomClassNames } from "./RequiresConfirmationController";
import RequiresConfirmationController from "./RequiresConfirmationController";
@@ -676,6 +674,43 @@ export const EventAdvancedTab = ({
/>
</div>
</div>
{!isPlatform && (
<Controller
name="requiresCancellationReason"
render={({ field: { value, onChange } }) => {
const cancellationReasonOptions = [
{ value: CancellationReasonRequirement.MANDATORY_BOTH, label: t("mandatory_for_both") },
{
value: CancellationReasonRequirement.MANDATORY_HOST_ONLY,
label: t("mandatory_for_host_only"),
},
{
value: CancellationReasonRequirement.MANDATORY_ATTENDEE_ONLY,
label: t("mandatory_for_attendee_only"),
},
{ value: CancellationReasonRequirement.OPTIONAL_BOTH, label: t("optional_for_both") },
];
return (
<div className="border-subtle rounded-lg border px-4 py-6 sm:px-6">
<div className="flex items-center justify-between">
<div>
<p className="text-default text-sm font-semibold">{t("require_cancellation_reason")}</p>
<p className="text-default text-sm">{t("require_cancellation_reason_description")}</p>
</div>
<Select
value={cancellationReasonOptions.find(
(opt) => opt.value === (value || CancellationReasonRequirement.MANDATORY_HOST_ONLY)
)}
options={cancellationReasonOptions}
onChange={(selected) => onChange(selected?.value)}
className="w-52"
/>
</div>
</div>
);
}}
/>
)}
<RequiresConfirmationController
eventType={eventType}
seatsEnabled={seatsEnabled}
@@ -0,0 +1,21 @@
import { CancellationReasonRequirement } from "@calcom/prisma/enums";
export function isCancellationReasonRequired(
setting: CancellationReasonRequirement | null | undefined,
isHost: boolean
): boolean {
const requirement = setting ?? CancellationReasonRequirement.MANDATORY_HOST_ONLY;
switch (requirement) {
case CancellationReasonRequirement.OPTIONAL_BOTH:
return false;
case CancellationReasonRequirement.MANDATORY_BOTH:
return true;
case CancellationReasonRequirement.MANDATORY_HOST_ONLY:
return isHost;
case CancellationReasonRequirement.MANDATORY_ATTENDEE_ONLY:
return !isHost;
default:
return false;
}
}
@@ -82,6 +82,7 @@ export async function getBookingToDelete(id: number | undefined, uid: string | u
length: true,
seatsPerTimeSlot: true,
disableCancelling: true,
requiresCancellationReason: true,
bookingFields: true,
seatsShowAttendees: true,
metadata: true,
@@ -47,6 +47,8 @@ import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
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";
@@ -208,15 +210,15 @@ async function handler(input: CancelBookingInput, dependencies?: Dependencies) {
const isCancellationUserHost =
bookingToDelete.userId === userId || bookingToDelete.user.email === cancelledBy;
if (
!platformClientId &&
!cancellationReason?.trim() &&
isCancellationUserHost &&
!skipCancellationReasonValidation
) {
const isReasonRequired = isCancellationReasonRequired(
bookingToDelete.eventType?.requiresCancellationReason,
isCancellationUserHost
);
if (!platformClientId && !cancellationReason?.trim() && isReasonRequired && !skipCancellationReasonValidation) {
throw new HttpError({
statusCode: 400,
message: "Cancellation reason is required when you are the host",
message: "Cancellation reason is required",
});
}
@@ -715,7 +715,7 @@ describe("Cancel Booking", () => {
},
actionSource: "WEBAPP",
})
).rejects.toThrow("Cancellation reason is required when you are the host");
).rejects.toThrow("Cancellation reason is required");
});
test("Should not charge cancellation fee when organizer cancels booking", async () => {
@@ -1667,4 +1667,660 @@ describe("Cancel Booking", () => {
expect(result.onlyRemovedAttendee).toBe(false);
expect(result.bookingId).toBe(idOfBookingToBeCancelled);
});
describe("Cancellation Reason Requirement", () => {
test("Should block host cancellation without reason when requiresCancellationReason is MANDATORY_BOTH", 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 = "mandatory-both-host-test";
const idOfBookingToBeCancelled = 8001;
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
await createBookingScenario(
getScenarioData({
eventTypes: [
{
id: 1,
slotInterval: 30,
length: 30,
requiresCancellationReason: "MANDATORY_BOTH",
users: [{ id: 101 }],
},
],
bookings: [
{
id: idOfBookingToBeCancelled,
uid: uidOfBookingToBeCancelled,
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:30:00.000Z`,
},
],
organizer,
apps: [TestData.apps["daily-video"]],
})
);
await expect(
handleCancelBooking({
bookingData: {
id: idOfBookingToBeCancelled,
uid: uidOfBookingToBeCancelled,
cancelledBy: organizer.email,
},
})
).rejects.toThrow("Cancellation reason is required");
});
test("Should block attendee cancellation without reason when requiresCancellationReason is MANDATORY_BOTH", 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 = "mandatory-both-attendee-test";
const idOfBookingToBeCancelled = 8002;
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
await createBookingScenario(
getScenarioData({
eventTypes: [
{
id: 1,
slotInterval: 30,
length: 30,
requiresCancellationReason: "MANDATORY_BOTH",
users: [{ id: 101 }],
},
],
bookings: [
{
id: idOfBookingToBeCancelled,
uid: uidOfBookingToBeCancelled,
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:30:00.000Z`,
},
],
organizer,
apps: [TestData.apps["daily-video"]],
})
);
await expect(
handleCancelBooking({
bookingData: {
id: idOfBookingToBeCancelled,
uid: uidOfBookingToBeCancelled,
cancelledBy: booker.email,
},
})
).rejects.toThrow("Cancellation reason is required");
});
test("Should block attendee cancellation without reason when requiresCancellationReason is MANDATORY_ATTENDEE_ONLY", 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 = "mandatory-attendee-only-test";
const idOfBookingToBeCancelled = 8003;
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
await createBookingScenario(
getScenarioData({
eventTypes: [
{
id: 1,
slotInterval: 30,
length: 30,
requiresCancellationReason: "MANDATORY_ATTENDEE_ONLY",
users: [{ id: 101 }],
},
],
bookings: [
{
id: idOfBookingToBeCancelled,
uid: uidOfBookingToBeCancelled,
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:30:00.000Z`,
},
],
organizer,
apps: [TestData.apps["daily-video"]],
})
);
await expect(
handleCancelBooking({
bookingData: {
id: idOfBookingToBeCancelled,
uid: uidOfBookingToBeCancelled,
cancelledBy: booker.email,
},
})
).rejects.toThrow("Cancellation reason is required");
});
test("Should allow host cancellation without reason when requiresCancellationReason is MANDATORY_ATTENDEE_ONLY", 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 = "mandatory-attendee-host-allowed-test";
const idOfBookingToBeCancelled = 8004;
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
await createBookingScenario(
getScenarioData({
eventTypes: [
{
id: 1,
slotInterval: 30,
length: 30,
requiresCancellationReason: "MANDATORY_ATTENDEE_ONLY",
users: [{ id: 101 }],
},
],
bookings: [
{
id: idOfBookingToBeCancelled,
uid: uidOfBookingToBeCancelled,
eventTypeId: 1,
userId: 101,
attendees: [{ email: booker.email, timeZone: "Asia/Kolkata" }],
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: BookingLocations.CalVideo },
},
status: BookingStatus.ACCEPTED,
startTime: `${plus1DateString}T05:00:00.000Z`,
endTime: `${plus1DateString}T05:30:00.000Z`,
},
],
organizer,
apps: [TestData.apps["daily-video"]],
})
);
mockSuccessfulVideoMeetingCreation({
metadataLookupKey: "dailyvideo",
videoMeetingData: {
id: "MOCK_ID",
password: "MOCK_PASS",
url: `http://mock-dailyvideo.example.com/meeting-attendee-only`,
},
});
mockCalendarToHaveNoBusySlots("googlecalendar", {
create: {
id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID_ATTENDEE_ONLY",
},
});
const result = await handleCancelBooking({
bookingData: {
id: idOfBookingToBeCancelled,
uid: uidOfBookingToBeCancelled,
cancelledBy: organizer.email,
},
});
expect(result.success).toBe(true);
});
test("Should allow host cancellation without reason when requiresCancellationReason is OPTIONAL_BOTH", 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 = "optional-both-host-test";
const idOfBookingToBeCancelled = 8005;
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
await createBookingScenario(
getScenarioData({
eventTypes: [
{
id: 1,
slotInterval: 30,
length: 30,
requiresCancellationReason: "OPTIONAL_BOTH",
users: [{ id: 101 }],
},
],
bookings: [
{
id: idOfBookingToBeCancelled,
uid: uidOfBookingToBeCancelled,
eventTypeId: 1,
userId: 101,
attendees: [{ email: booker.email, timeZone: "Asia/Kolkata" }],
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: BookingLocations.CalVideo },
},
status: BookingStatus.ACCEPTED,
startTime: `${plus1DateString}T05:00:00.000Z`,
endTime: `${plus1DateString}T05:30:00.000Z`,
},
],
organizer,
apps: [TestData.apps["daily-video"]],
})
);
mockSuccessfulVideoMeetingCreation({
metadataLookupKey: "dailyvideo",
videoMeetingData: {
id: "MOCK_ID",
password: "MOCK_PASS",
url: `http://mock-dailyvideo.example.com/meeting-optional-host`,
},
});
mockCalendarToHaveNoBusySlots("googlecalendar", {
create: {
id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID_OPTIONAL_HOST",
},
});
const result = await handleCancelBooking({
bookingData: {
id: idOfBookingToBeCancelled,
uid: uidOfBookingToBeCancelled,
cancelledBy: organizer.email,
},
});
expect(result.success).toBe(true);
});
test("Should allow attendee cancellation without reason when requiresCancellationReason is OPTIONAL_BOTH", 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 = "optional-both-attendee-test";
const idOfBookingToBeCancelled = 8006;
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
await createBookingScenario(
getScenarioData({
eventTypes: [
{
id: 1,
slotInterval: 30,
length: 30,
requiresCancellationReason: "OPTIONAL_BOTH",
users: [{ id: 101 }],
},
],
bookings: [
{
id: idOfBookingToBeCancelled,
uid: uidOfBookingToBeCancelled,
eventTypeId: 1,
userId: 101,
attendees: [{ email: booker.email, timeZone: "Asia/Kolkata" }],
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: BookingLocations.CalVideo },
},
status: BookingStatus.ACCEPTED,
startTime: `${plus1DateString}T05:00:00.000Z`,
endTime: `${plus1DateString}T05:30:00.000Z`,
},
],
organizer,
apps: [TestData.apps["daily-video"]],
})
);
mockSuccessfulVideoMeetingCreation({
metadataLookupKey: "dailyvideo",
videoMeetingData: {
id: "MOCK_ID",
password: "MOCK_PASS",
url: `http://mock-dailyvideo.example.com/meeting-optional-attendee`,
},
});
mockCalendarToHaveNoBusySlots("googlecalendar", {
create: {
id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID_OPTIONAL_ATTENDEE",
},
});
const result = await handleCancelBooking({
bookingData: {
id: idOfBookingToBeCancelled,
uid: uidOfBookingToBeCancelled,
cancelledBy: booker.email,
},
});
expect(result.success).toBe(true);
});
test("Should allow attendee cancellation without reason when requiresCancellationReason is MANDATORY_HOST_ONLY", 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 = "mandatory-host-attendee-allowed-test";
const idOfBookingToBeCancelled = 8007;
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
await createBookingScenario(
getScenarioData({
eventTypes: [
{
id: 1,
slotInterval: 30,
length: 30,
requiresCancellationReason: "MANDATORY_HOST_ONLY",
users: [{ id: 101 }],
},
],
bookings: [
{
id: idOfBookingToBeCancelled,
uid: uidOfBookingToBeCancelled,
eventTypeId: 1,
userId: 101,
attendees: [{ email: booker.email, timeZone: "Asia/Kolkata" }],
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: BookingLocations.CalVideo },
},
status: BookingStatus.ACCEPTED,
startTime: `${plus1DateString}T05:00:00.000Z`,
endTime: `${plus1DateString}T05:30:00.000Z`,
},
],
organizer,
apps: [TestData.apps["daily-video"]],
})
);
mockSuccessfulVideoMeetingCreation({
metadataLookupKey: "dailyvideo",
videoMeetingData: {
id: "MOCK_ID",
password: "MOCK_PASS",
url: `http://mock-dailyvideo.example.com/meeting-host-only-attendee`,
},
});
mockCalendarToHaveNoBusySlots("googlecalendar", {
create: {
id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID_HOST_ONLY_ATTENDEE",
},
});
const result = await handleCancelBooking({
bookingData: {
id: idOfBookingToBeCancelled,
uid: uidOfBookingToBeCancelled,
cancelledBy: booker.email,
},
});
expect(result.success).toBe(true);
});
test("Should block host cancellation without reason when requiresCancellationReason is null (default behavior)", 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 = "null-default-host-test";
const idOfBookingToBeCancelled = 8008;
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
await createBookingScenario(
getScenarioData({
eventTypes: [
{
id: 1,
slotInterval: 30,
length: 30,
users: [{ id: 101 }],
},
],
bookings: [
{
id: idOfBookingToBeCancelled,
uid: uidOfBookingToBeCancelled,
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:30:00.000Z`,
},
],
organizer,
apps: [TestData.apps["daily-video"]],
})
);
await expect(
handleCancelBooking({
bookingData: {
id: idOfBookingToBeCancelled,
uid: uidOfBookingToBeCancelled,
cancelledBy: organizer.email,
},
})
).rejects.toThrow("Cancellation reason is required");
});
test("Should allow attendee cancellation without reason when requiresCancellationReason is null (default behavior)", 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 = "null-default-attendee-test";
const idOfBookingToBeCancelled = 8009;
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
await createBookingScenario(
getScenarioData({
eventTypes: [
{
id: 1,
slotInterval: 30,
length: 30,
users: [{ id: 101 }],
},
],
bookings: [
{
id: idOfBookingToBeCancelled,
uid: uidOfBookingToBeCancelled,
eventTypeId: 1,
userId: 101,
attendees: [{ email: booker.email, timeZone: "Asia/Kolkata" }],
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: BookingLocations.CalVideo },
},
status: BookingStatus.ACCEPTED,
startTime: `${plus1DateString}T05:00:00.000Z`,
endTime: `${plus1DateString}T05:30:00.000Z`,
},
],
organizer,
apps: [TestData.apps["daily-video"]],
})
);
mockSuccessfulVideoMeetingCreation({
metadataLookupKey: "dailyvideo",
videoMeetingData: {
id: "MOCK_ID",
password: "MOCK_PASS",
url: `http://mock-dailyvideo.example.com/meeting-null-default`,
},
});
mockCalendarToHaveNoBusySlots("googlecalendar", {
create: {
id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID_NULL_DEFAULT",
},
});
const result = await handleCancelBooking({
bookingData: {
id: idOfBookingToBeCancelled,
uid: uidOfBookingToBeCancelled,
cancelledBy: booker.email,
},
});
expect(result.success).toBe(true);
});
});
});
@@ -89,6 +89,7 @@ const commons = {
seatsShowAvailabilityCount: null,
disableCancelling: false,
disableRescheduling: false,
requiresCancellationReason: null,
minimumRescheduleNotice: null,
onlyShowFirstAvailableSlot: false,
allowReschedulingPastBookings: false,
+2 -1
View File
@@ -6,7 +6,7 @@ import type { ChildrenEventType } from "@calcom/features/eventtypes/lib/children
import type { IntervalLimit } from "@calcom/lib/intervalLimits/intervalLimitSchema";
import type { AttributesQueryValue } from "@calcom/lib/raqb/types";
import type { EventTypeTranslation } from "@calcom/prisma/client";
import type { MembershipRole, PeriodType, SchedulingType } from "@calcom/prisma/enums";
import type { CancellationReasonRequirement, MembershipRole, PeriodType, SchedulingType } from "@calcom/prisma/enums";
import type {
BookerLayoutSettings,
CustomInputSchema,
@@ -388,6 +388,7 @@ export type EventTypeUpdateInput = {
showOptimizedSlots?: boolean | null;
disableCancelling?: boolean | null;
disableRescheduling?: boolean | null;
requiresCancellationReason?: CancellationReasonRequirement | null;
minimumRescheduleNotice?: number | null;
seatsShowAttendees?: boolean | null;
seatsShowAvailabilityCount?: boolean | null;
+10 -4
View File
@@ -84,9 +84,9 @@
"need_to_reschedule_or_cancel": "Need to reschedule or cancel?",
"you_can_view_booking_details_with_this_url": "You can view the booking details from this url {{url}} and add the event to your calendar",
"no_options_available": "No options available",
"cancellation_reason": "Reason for cancellation (optional)",
"cancellation_reason": "Reason for cancellation",
"cancellation_reason_optional_label": "Reason for cancellation (optional)",
"cancelled_by": "Cancelled By",
"cancellation_reason_host": "Reason for cancellation",
"cancellation_reason_placeholder": "Why are you cancelling?",
"notify_attendee_cancellation_reason_warning": "Cancellation reason will be shared with guests",
"rejection_reason_placeholder": "Why are you rejecting?",
@@ -1507,8 +1507,14 @@
"rescheduling_is_disabled": "Rescheduling is disabled for this event",
"allow_rescheduling_cancelled_bookings": "Allow booking through reschedule link",
"description_allow_rescheduling_cancelled_bookings": "When enabled, users will be able to create a new booking when trying to reschedule a cancelled booking",
"disable_cancelling": "Disable cancelling",
"description_disable_cancelling": "Guests and organizer can no longer cancel the event with calendar invite or email. <0>Learn more</0>",
"disable_cancelling": "Disable Cancelling",
"description_disable_cancelling": "Guests and Organizer can no longer cancel the event with calendar invite or email. <0>Learn more</0>",
"require_cancellation_reason": "Require cancellation reason",
"require_cancellation_reason_description": "Ask for a reason when someone cancels a booking",
"mandatory_for_both": "Mandatory for both",
"mandatory_for_host_only": "Mandatory for host only",
"mandatory_for_attendee_only": "Mandatory for attendee only",
"optional_for_both": "Optional for both",
"revoke_api_key": "Revoke API key",
"api_key_copied": "API key copied!",
"api_key_expires_on": "The API key will expire on",
+1
View File
@@ -168,6 +168,7 @@ export const buildEventType = (eventType?: Partial<EventType>): EventType => {
createdAt: null,
updatedAt: null,
rrHostSubsetEnabled: false,
requiresCancellationReason: null,
enablePerHostLocations: false,
...eventType,
};
@@ -0,0 +1,5 @@
-- CreateEnum
CREATE TYPE "public"."CancellationReasonRequirement" AS ENUM ('MANDATORY_BOTH', 'MANDATORY_HOST_ONLY', 'MANDATORY_ATTENDEE_ONLY', 'OPTIONAL_BOTH');
-- AlterTable
ALTER TABLE "public"."EventType" ADD COLUMN "requiresCancellationReason" "public"."CancellationReasonRequirement" DEFAULT 'MANDATORY_HOST_ONLY';
+10 -2
View File
@@ -146,6 +146,13 @@ model VideoCallGuest {
@@index([email])
}
enum CancellationReasonRequirement {
MANDATORY_BOTH
MANDATORY_HOST_ONLY
MANDATORY_ATTENDEE_ONLY
OPTIONAL_BOTH
}
model EventType {
id Int @id @default(autoincrement())
/// @zod.string.min(1)
@@ -279,8 +286,9 @@ model EventType {
restrictionSchedule Schedule? @relation("restrictionSchedule", fields: [restrictionScheduleId], references: [id])
hostGroups HostGroup[]
bookingRequiresAuthentication Boolean @default(false)
rrHostSubsetEnabled Boolean @default(false)
bookingRequiresAuthentication Boolean @default(false)
rrHostSubsetEnabled Boolean @default(false)
requiresCancellationReason CancellationReasonRequirement? @default(MANDATORY_HOST_ONLY)
enablePerHostLocations Boolean @default(false)
createdAt DateTime? @default(now())
+2
View File
@@ -771,6 +771,7 @@ export const allManagedEventTypeProps: { [k in keyof Omit<Prisma.EventTypeSelect
disableGuests: true,
disableCancelling: true,
disableRescheduling: true,
requiresCancellationReason: true,
allowReschedulingCancelledBookings: true,
requiresConfirmation: true,
canSendCalVideoTranscriptionEmails: true,
@@ -853,6 +854,7 @@ export const allManagedEventTypePropsForZod = {
disableGuests: true,
disableCancelling: true,
disableRescheduling: true,
requiresCancellationReason: true,
allowReschedulingCancelledBookings: true,
requiresConfirmation: true,
canSendCalVideoTranscriptionEmails: true,
@@ -165,6 +165,10 @@ const BaseEventTypeUpdateInput: z.ZodType<TUpdateInputSchema> = z
showOptimizedSlots: z.boolean().nullable().optional(),
disableCancelling: z.boolean().nullable().optional(),
disableRescheduling: z.boolean().nullable().optional(),
requiresCancellationReason: z
.enum(["MANDATORY_BOTH", "MANDATORY_HOST_ONLY", "MANDATORY_ATTENDEE_ONLY", "OPTIONAL_BOTH"])
.nullable()
.optional(),
minimumRescheduleNotice: z.number().min(0).nullable().optional(),
seatsShowAttendees: z.boolean().nullable().optional(),
seatsShowAvailabilityCount: z.boolean().nullable().optional(),
@@ -0,0 +1,23 @@
# CLAUDE.md — Cancellation Reason Requirement
## Project Context
A setting in Event Type Advanced settings to configure when cancellation reasons are required (mandatory for both, host only, attendee only, or optional).
## Before Starting Work
1. Read specs/cancellation-reason-requirement/design.md
2. Check specs/cancellation-reason-requirement/implementation.md for current progress
3. Look at existing patterns in apps/web/modules/event-types/components/tabs/advanced/
## Code Patterns
- Follow RequiresConfirmationController pattern for settings UI
- Use metadata schema in packages/prisma/zod-utils.ts
- Follow existing translation patterns
## Don't
- Don't add features not in design.md
- Don't skip tests
- Don't modify reschedule reason behavior (out of scope)
@@ -0,0 +1,28 @@
# Cancellation Reason Requirement Decisions
## ADR-001: Store in Database Column vs Metadata JSON
### Context
Need to store the cancellation reason requirement setting on EventType.
### Options Considered
1. **New database column with enum** — Requires migration, type-safe, cleaner queries
2. **Metadata JSON field** — No migration, but less type-safe for a core setting
### Decision
Use a dedicated database column with a Prisma enum (`CancellationReasonRequirement`).
Rationale:
- This is a core booking flow setting, similar to `disableCancelling` and `requiresConfirmation`
- Type-safe at the database level
- Cleaner to query in cancellation validation logic
- Consistent with how similar settings (`disableCancelling`, `disableRescheduling`) are stored
### Consequences
- Requires database migration
- Type-safe enum values
- Direct column access in queries (no JSON parsing)
@@ -0,0 +1,79 @@
# Cancellation Reason Requirement Design
## Overview
Add a dropdown setting in Event Type Advanced settings that allows hosts to configure when cancellation reasons are required from hosts and/or attendees.
## Problem Statement
Currently, cancellation reasons are always optional. Hosts need the ability to require reasons for better tracking and accountability.
## User Stories
- As a host, I want to require cancellation reasons from attendees so that I understand why bookings are cancelled
- As a host, I want to require my team to provide cancellation reasons so that we have records of why bookings were cancelled
- As a host, I want to make cancellation reasons optional when they're not needed
## Technical Design
### Database Changes
Add new enum `CancellationReasonRequirement` with values:
- `MANDATORY_BOTH`
- `MANDATORY_HOST_ONLY`
- `MANDATORY_ATTENDEE_ONLY`
- `OPTIONAL_BOTH`
Add column `requiresCancellationReason` to EventType model with default `MANDATORY_HOST_ONLY`.
Location: `packages/prisma/schema.prisma` (near `disableCancelling`/`disableRescheduling`)
### API Changes
Update `packages/features/bookings/lib/handleCancelBooking.ts` to validate cancellation reason based on:
- Event type's `requiresCancellationReason` setting
- Who is cancelling (host vs attendee)
### UI Changes
**Event Type Settings**
Location: `apps/web/modules/event-types/components/tabs/advanced/EventAdvancedTab.tsx`
Add dropdown after Booking Questions section, before RequiresConfirmationController:
- Label: "Require cancellation reason"
- Description: "Ask for a reason when someone cancels a booking"
- Options: Mandatory for both, Mandatory for host only (default), Mandatory for attendee only, Optional for both
**Cancel Booking**
Location: `apps/web/components/booking/CancelBooking.tsx`
- Add `requiresCancellationReason` prop
- Replace hardcoded `hostMissingCancellationReason` logic with configurable validation based on the setting
- Show required indicator on textarea when reason is required
## Data Flow
1. EventType stores `requiresCancellationReason` in database
2. `getEventTypesFromDB` (`apps/web/lib/booking.ts`) includes the field in select
3. Value flows through page props to booking views
4. `CancelBooking` component uses it for validation
Files requiring prop threading:
- `apps/web/lib/booking.ts`
- `apps/web/modules/bookings/views/bookings-single-view.tsx`
- `apps/web/components/dialog/CancelBookingDialog.tsx`
## Edge Cases
- Platform users: Should respect the setting
- Team bookings: Setting applies regardless of team context
- Null column value: Default to `MANDATORY_HOST_ONLY` behavior
- Default event types (no eventTypeId): Use default `MANDATORY_HOST_ONLY`
## Out of Scope
- Reschedule reason configuration (separate feature)
- Custom reason dropdown options
- Reason analytics/reporting
@@ -0,0 +1,20 @@
# Cancellation Reason Requirement Documentation
## Overview
This feature allows event type owners to configure when cancellation reasons are required.
## Screenshots
Screenshots will be added here once the feature is implemented.
## Configuration
The setting is located in Event Type → Advanced Settings, after the Booking Questions section.
### Options
- **Mandatory for both**: Both host and attendee must provide a reason when cancelling
- **Mandatory for host only** (default): Only the host must provide a reason
- **Mandatory for attendee only**: Only the attendee must provide a reason
- **Optional for both**: Cancellation reason is optional for everyone
@@ -0,0 +1,16 @@
# Cancellation Reason Requirement Future Work
Ideas and enhancements deferred from initial implementation.
## Enhancements
- Reschedule reason requirement (same pattern, separate setting)
- Custom predefined reason options (dropdown instead of free text)
- Reason analytics dashboard
## Technical Debt
## Nice to Have
- Per-user reason requirement overrides
- Reason templates
@@ -0,0 +1,34 @@
# Cancellation Reason Requirement Implementation
## Status: complete
## Completed
1. Added CancellationReasonRequirement enum to schema.prisma (line 129)
2. Added requiresCancellationReason column to EventType model (line 269)
3. Created database migration (20260115111819_add_cancellation_reason_require)
4. Added translation keys to English locale (common.json)
5. Added dropdown setting in EventAdvancedTab (lines 691-719)
6. Added requiresCancellationReason to getEventTypesFromDB select (apps/web/lib/booking.ts)
7. Passed requiresCancellationReason prop through:
- bookings-single-view.tsx → CancelBooking
- CancelBookingDialog.tsx → CancelBooking
8. Updated CancelBooking component Props and validation logic
9. Added server-side validation in handleCancelBooking
10. Added requiresCancellationReason to getBookingToDelete select
11. Fixed dynamic label to show "(optional)" only when isReasonRequiredForUser() returns false
## In Progress
## Blocked
## Next Steps
- Test the feature end-to-end
- Verify all dropdown options work correctly
- Verify dynamic label shows "(optional)" only when appropriate
## Session Notes
- Enum and column were already added to schema during planning phase
- Migration was already created