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:
@@ -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 () => {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+657
-1
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -168,6 +168,7 @@ export const buildEventType = (eventType?: Partial<EventType>): EventType => {
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
rrHostSubsetEnabled: false,
|
||||
requiresCancellationReason: null,
|
||||
enablePerHostLocations: false,
|
||||
...eventType,
|
||||
};
|
||||
|
||||
+5
@@ -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';
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user