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:
co-authored by
Keith Williams
Devin AI
parent
c9abc556ba
commit
e3a9f54ba5
@@ -1053,7 +1053,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||||
.expect(400);
|
.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 () => {
|
it("should cancel seated booking", async () => {
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useState } from "react";
|
|
||||||
|
|
||||||
import { sdkActionManager } from "@calcom/embed-core/embed-iframe";
|
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 { shouldChargeNoShowCancellationFee } from "@calcom/features/bookings/lib/payment/shouldChargeNoShowCancellationFee";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { useRefreshData } from "@calcom/lib/hooks/useRefreshData";
|
import { useRefreshData } from "@calcom/lib/hooks/useRefreshData";
|
||||||
|
import type { CancellationReasonRequirement } from "@calcom/prisma/enums";
|
||||||
import type { RecurringEvent } from "@calcom/types/Calendar";
|
import type { RecurringEvent } from "@calcom/types/Calendar";
|
||||||
import classNames from "@calcom/ui/classNames";
|
import classNames from "@calcom/ui/classNames";
|
||||||
import { Button } from "@calcom/ui/components/button";
|
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 { showToast } from "@calcom/ui/components/toast";
|
||||||
import { InfoIcon, XIcon } from "@coss/ui/icons";
|
import { InfoIcon, XIcon } from "@coss/ui/icons";
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
|
||||||
interface InternalNotePresetsSelectProps {
|
interface InternalNotePresetsSelectProps {
|
||||||
internalNotePresets: { id: number; name: string }[];
|
internalNotePresets: { id: number; name: string }[];
|
||||||
@@ -110,6 +111,7 @@ type Props = {
|
|||||||
internalNotePresets: { id: number; name: string; cancellationReason: string | null }[];
|
internalNotePresets: { id: number; name: string; cancellationReason: string | null }[];
|
||||||
renderContext: "booking-single-view" | "dialog";
|
renderContext: "booking-single-view" | "dialog";
|
||||||
eventTypeMetadata?: Record<string, unknown> | null;
|
eventTypeMetadata?: Record<string, unknown> | null;
|
||||||
|
requiresCancellationReason?: CancellationReasonRequirement | null;
|
||||||
showErrorAsToast?: boolean;
|
showErrorAsToast?: boolean;
|
||||||
onCanceled?: () => void;
|
onCanceled?: () => void;
|
||||||
};
|
};
|
||||||
@@ -163,11 +165,18 @@ export default function CancelBooking(props: Props) {
|
|||||||
const isCancellationUserHost =
|
const isCancellationUserHost =
|
||||||
props.isHost || bookingCancelledEventProps.organizer.email === currentUserEmail;
|
props.isHost || bookingCancelledEventProps.organizer.email === currentUserEmail;
|
||||||
|
|
||||||
const hostMissingCancellationReason =
|
const isReasonRequired = isCancellationReasonRequired(
|
||||||
isCancellationUserHost &&
|
props.requiresCancellationReason,
|
||||||
(!cancellationReason?.trim() || (props.internalNotePresets.length > 0 && !internalNote?.id));
|
isCancellationUserHost
|
||||||
|
);
|
||||||
|
|
||||||
|
const missingRequiredReason = isReasonRequired && !cancellationReason?.trim();
|
||||||
|
const hostMissingInternalNote =
|
||||||
|
isCancellationUserHost && props.internalNotePresets.length > 0 && !internalNote?.id;
|
||||||
const cancellationNoShowFeeNotAcknowledged =
|
const cancellationNoShowFeeNotAcknowledged =
|
||||||
!props.isHost && cancellationNoShowFeeWarning && !acknowledgeCancellationNoShowFee;
|
!props.isHost && cancellationNoShowFeeWarning && !acknowledgeCancellationNoShowFee;
|
||||||
|
const canCancel =
|
||||||
|
!missingRequiredReason && !hostMissingInternalNote && !cancellationNoShowFeeNotAcknowledged;
|
||||||
const cancelBookingRef = useCallback((node: HTMLTextAreaElement) => {
|
const cancelBookingRef = useCallback((node: HTMLTextAreaElement) => {
|
||||||
if (node !== null) {
|
if (node !== null) {
|
||||||
// eslint-disable-next-line @calcom/eslint/no-scroll-into-view-embed -- CancelBooking is not usually used in embed mode
|
// 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
|
<TextArea
|
||||||
data-testid="cancel_reason"
|
data-testid="cancel_reason"
|
||||||
@@ -325,7 +334,7 @@ export default function CancelBooking(props: Props) {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
data-testid="confirm_cancel"
|
data-testid="confirm_cancel"
|
||||||
disabled={hostMissingCancellationReason || cancellationNoShowFeeNotAcknowledged}
|
disabled={!canCancel}
|
||||||
onClick={handleCancel}
|
onClick={handleCancel}
|
||||||
loading={loading}>
|
loading={loading}>
|
||||||
{props.allRemainingBookings ? t("cancel_all_remaining") : t("cancel_event")}
|
{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 { Dialog } from "@calcom/features/components/controlled-dialog";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import type { CancellationReasonRequirement } from "@calcom/prisma/enums";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import type { RecurringEvent } from "@calcom/types/Calendar";
|
import type { RecurringEvent } from "@calcom/types/Calendar";
|
||||||
import { DialogContent, DialogHeader } from "@calcom/ui/components/dialog";
|
import { DialogContent, DialogHeader } from "@calcom/ui/components/dialog";
|
||||||
@@ -48,6 +49,7 @@ interface ICancelBookingDialog {
|
|||||||
isHost: boolean;
|
isHost: boolean;
|
||||||
internalNotePresets?: { id: number; name: string; cancellationReason: string | null }[];
|
internalNotePresets?: { id: number; name: string; cancellationReason: string | null }[];
|
||||||
eventTypeMetadata?: Record<string, unknown> | null;
|
eventTypeMetadata?: Record<string, unknown> | null;
|
||||||
|
requiresCancellationReason?: CancellationReasonRequirement | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CancelBookingDialog = (props: ICancelBookingDialog) => {
|
export const CancelBookingDialog = (props: ICancelBookingDialog) => {
|
||||||
@@ -67,6 +69,7 @@ export const CancelBookingDialog = (props: ICancelBookingDialog) => {
|
|||||||
isHost,
|
isHost,
|
||||||
internalNotePresets = [],
|
internalNotePresets = [],
|
||||||
eventTypeMetadata,
|
eventTypeMetadata,
|
||||||
|
requiresCancellationReason,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
@@ -113,6 +116,7 @@ export const CancelBookingDialog = (props: ICancelBookingDialog) => {
|
|||||||
isHost={isHost}
|
isHost={isHost}
|
||||||
internalNotePresets={internalNotePresets}
|
internalNotePresets={internalNotePresets}
|
||||||
eventTypeMetadata={eventTypeMetadata}
|
eventTypeMetadata={eventTypeMetadata}
|
||||||
|
requiresCancellationReason={requiresCancellationReason}
|
||||||
showErrorAsToast={true}
|
showErrorAsToast={true}
|
||||||
onCanceled={handleCanceled}
|
onCanceled={handleCanceled}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export const getEventTypesFromDB = async (id: number) => {
|
|||||||
hideOrganizerEmail: true,
|
hideOrganizerEmail: true,
|
||||||
disableCancelling: true,
|
disableCancelling: true,
|
||||||
disableRescheduling: true,
|
disableRescheduling: true,
|
||||||
|
requiresCancellationReason: true,
|
||||||
minimumRescheduleNotice: true,
|
minimumRescheduleNotice: true,
|
||||||
disableGuests: true,
|
disableGuests: true,
|
||||||
timeZone: true,
|
timeZone: true,
|
||||||
|
|||||||
@@ -938,6 +938,7 @@ export default function Success(props: PageProps) {
|
|||||||
payment: props.paymentStatus,
|
payment: props.paymentStatus,
|
||||||
}}
|
}}
|
||||||
eventTypeMetadata={eventType.metadata}
|
eventTypeMetadata={eventType.metadata}
|
||||||
|
requiresCancellationReason={eventType.requiresCancellationReason}
|
||||||
profile={{ name: props.profile.name, slug: props.profile.slug }}
|
profile={{ name: props.profile.name, slug: props.profile.slug }}
|
||||||
recurringEvent={eventType.recurringEvent}
|
recurringEvent={eventType.recurringEvent}
|
||||||
team={eventType?.team?.name}
|
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 { getPaymentAppData } from "@calcom/app-store/_utils/payments/getPaymentAppData";
|
||||||
import { useAtomsContext } from "@calcom/atoms/hooks/useAtomsContext";
|
import { useAtomsContext } from "@calcom/atoms/hooks/useAtomsContext";
|
||||||
import { useIsPlatform } from "@calcom/atoms/hooks/useIsPlatform";
|
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 { Timezone as PlatformTimzoneSelect } from "@calcom/atoms/timezone";
|
||||||
import getLocationsOptionsForSelect from "@calcom/features/bookings/lib/getLocationOptionsForSelect";
|
import getLocationsOptionsForSelect from "@calcom/features/bookings/lib/getLocationOptionsForSelect";
|
||||||
import DestinationCalendarSelector from "@calcom/features/calendars/components/DestinationCalendarSelector";
|
import DestinationCalendarSelector from "@calcom/features/calendars/components/DestinationCalendarSelector";
|
||||||
@@ -20,24 +10,20 @@ import {
|
|||||||
allowDisablingAttendeeConfirmationEmails,
|
allowDisablingAttendeeConfirmationEmails,
|
||||||
allowDisablingHostConfirmationEmails,
|
allowDisablingHostConfirmationEmails,
|
||||||
} from "@calcom/features/ee/workflows/lib/allowDisablingStandardEmails";
|
} 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 type { EventNameObjectType } from "@calcom/features/eventtypes/lib/eventNaming";
|
||||||
import { getEventName } from "@calcom/features/eventtypes/lib/eventNaming";
|
import { getEventName } from "@calcom/features/eventtypes/lib/eventNaming";
|
||||||
import type {
|
import type {
|
||||||
FormValues,
|
|
||||||
EventTypeSetupProps,
|
|
||||||
SelectClassNames,
|
|
||||||
CheckboxClassNames,
|
CheckboxClassNames,
|
||||||
|
EventTypeSetupProps,
|
||||||
|
FormValues,
|
||||||
InputClassNames,
|
InputClassNames,
|
||||||
|
SelectClassNames,
|
||||||
SettingsToggleClassNames,
|
SettingsToggleClassNames,
|
||||||
} from "@calcom/features/eventtypes/lib/types";
|
} from "@calcom/features/eventtypes/lib/types";
|
||||||
import { FormBuilder } from "./FormBuilder";
|
|
||||||
import { BookerLayoutSelector } from "@calcom/web/modules/settings/components/BookerLayoutSelector";
|
import { BookerLayoutSelector } from "@calcom/web/modules/settings/components/BookerLayoutSelector";
|
||||||
import {
|
import {
|
||||||
DEFAULT_LIGHT_BRAND_COLOR,
|
|
||||||
DEFAULT_DARK_BRAND_COLOR,
|
DEFAULT_DARK_BRAND_COLOR,
|
||||||
|
DEFAULT_LIGHT_BRAND_COLOR,
|
||||||
MAX_SEATS_PER_TIME_SLOT,
|
MAX_SEATS_PER_TIME_SLOT,
|
||||||
} from "@calcom/lib/constants";
|
} from "@calcom/lib/constants";
|
||||||
import { generateHashedLink } from "@calcom/lib/generateHashedLink";
|
import { generateHashedLink } from "@calcom/lib/generateHashedLink";
|
||||||
@@ -45,25 +31,36 @@ import { checkWCAGContrastColor } from "@calcom/lib/getBrandColours";
|
|||||||
import { extractHostTimezone } from "@calcom/lib/hashedLinksUtils";
|
import { extractHostTimezone } from "@calcom/lib/hashedLinksUtils";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import type { Prisma } from "@calcom/prisma/client";
|
import type { Prisma } from "@calcom/prisma/client";
|
||||||
import { SchedulingType } from "@calcom/prisma/enums";
|
import { CancellationReasonRequirement, SchedulingType } from "@calcom/prisma/enums";
|
||||||
import type { EditableSchema } from "@calcom/prisma/zod-utils";
|
import type { EditableSchema, fieldSchema } from "@calcom/prisma/zod-utils";
|
||||||
import type { fieldSchema } from "@calcom/prisma/zod-utils";
|
|
||||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||||
import classNames from "@calcom/ui/classNames";
|
import classNames from "@calcom/ui/classNames";
|
||||||
import { Alert } from "@calcom/ui/components/alert";
|
import { Alert } from "@calcom/ui/components/alert";
|
||||||
import { Badge } from "@calcom/ui/components/badge";
|
import { Badge } from "@calcom/ui/components/badge";
|
||||||
import { Button } from "@calcom/ui/components/button";
|
import { Button } from "@calcom/ui/components/button";
|
||||||
import {
|
import {
|
||||||
SelectField,
|
|
||||||
ColorPicker,
|
|
||||||
TextField,
|
|
||||||
Label,
|
|
||||||
CheckboxField,
|
CheckboxField,
|
||||||
Switch,
|
ColorPicker,
|
||||||
SettingsToggle,
|
Label,
|
||||||
Select,
|
Select,
|
||||||
|
SelectField,
|
||||||
|
SettingsToggle,
|
||||||
|
Switch,
|
||||||
|
TextField,
|
||||||
} from "@calcom/ui/components/form";
|
} from "@calcom/ui/components/form";
|
||||||
import { InfoIcon, PencilIcon } from "@coss/ui/icons";
|
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 type { CustomEventTypeModalClassNames } from "./CustomEventTypeModal";
|
||||||
import CustomEventTypeModal from "./CustomEventTypeModal";
|
import CustomEventTypeModal from "./CustomEventTypeModal";
|
||||||
@@ -71,6 +68,7 @@ import type { EmailNotificationToggleCustomClassNames } from "./DisableAllEmails
|
|||||||
import { DisableAllEmailsSetting } from "./DisableAllEmailsSetting";
|
import { DisableAllEmailsSetting } from "./DisableAllEmailsSetting";
|
||||||
import type { DisableReschedulingCustomClassNames } from "./DisableReschedulingController";
|
import type { DisableReschedulingCustomClassNames } from "./DisableReschedulingController";
|
||||||
import DisableReschedulingController from "./DisableReschedulingController";
|
import DisableReschedulingController from "./DisableReschedulingController";
|
||||||
|
import { FormBuilder } from "./FormBuilder";
|
||||||
import type { RequiresConfirmationCustomClassNames } from "./RequiresConfirmationController";
|
import type { RequiresConfirmationCustomClassNames } from "./RequiresConfirmationController";
|
||||||
import RequiresConfirmationController from "./RequiresConfirmationController";
|
import RequiresConfirmationController from "./RequiresConfirmationController";
|
||||||
|
|
||||||
@@ -676,6 +674,43 @@ export const EventAdvancedTab = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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
|
<RequiresConfirmationController
|
||||||
eventType={eventType}
|
eventType={eventType}
|
||||||
seatsEnabled={seatsEnabled}
|
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,
|
length: true,
|
||||||
seatsPerTimeSlot: true,
|
seatsPerTimeSlot: true,
|
||||||
disableCancelling: true,
|
disableCancelling: true,
|
||||||
|
requiresCancellationReason: true,
|
||||||
bookingFields: true,
|
bookingFields: true,
|
||||||
seatsShowAttendees: true,
|
seatsShowAttendees: true,
|
||||||
metadata: true,
|
metadata: true,
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
|
|||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
import type { WebhookTriggerEvents, WorkflowMethods } from "@calcom/prisma/enums";
|
import type { WebhookTriggerEvents, WorkflowMethods } from "@calcom/prisma/enums";
|
||||||
import { BookingStatus } from "@calcom/prisma/enums";
|
import { BookingStatus } from "@calcom/prisma/enums";
|
||||||
|
|
||||||
|
import { isCancellationReasonRequired } from "./cancellationReason";
|
||||||
import type { EventTypeMetadata } from "@calcom/prisma/zod-utils";
|
import type { EventTypeMetadata } from "@calcom/prisma/zod-utils";
|
||||||
import { bookingCancelInput, bookingMetadataSchema } from "@calcom/prisma/zod-utils";
|
import { bookingCancelInput, bookingMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||||
@@ -208,15 +210,15 @@ async function handler(input: CancelBookingInput, dependencies?: Dependencies) {
|
|||||||
const isCancellationUserHost =
|
const isCancellationUserHost =
|
||||||
bookingToDelete.userId === userId || bookingToDelete.user.email === cancelledBy;
|
bookingToDelete.userId === userId || bookingToDelete.user.email === cancelledBy;
|
||||||
|
|
||||||
if (
|
const isReasonRequired = isCancellationReasonRequired(
|
||||||
!platformClientId &&
|
bookingToDelete.eventType?.requiresCancellationReason,
|
||||||
!cancellationReason?.trim() &&
|
isCancellationUserHost
|
||||||
isCancellationUserHost &&
|
);
|
||||||
!skipCancellationReasonValidation
|
|
||||||
) {
|
if (!platformClientId && !cancellationReason?.trim() && isReasonRequired && !skipCancellationReasonValidation) {
|
||||||
throw new HttpError({
|
throw new HttpError({
|
||||||
statusCode: 400,
|
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",
|
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 () => {
|
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.onlyRemovedAttendee).toBe(false);
|
||||||
expect(result.bookingId).toBe(idOfBookingToBeCancelled);
|
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,
|
seatsShowAvailabilityCount: null,
|
||||||
disableCancelling: false,
|
disableCancelling: false,
|
||||||
disableRescheduling: false,
|
disableRescheduling: false,
|
||||||
|
requiresCancellationReason: null,
|
||||||
minimumRescheduleNotice: null,
|
minimumRescheduleNotice: null,
|
||||||
onlyShowFirstAvailableSlot: false,
|
onlyShowFirstAvailableSlot: false,
|
||||||
allowReschedulingPastBookings: 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 { IntervalLimit } from "@calcom/lib/intervalLimits/intervalLimitSchema";
|
||||||
import type { AttributesQueryValue } from "@calcom/lib/raqb/types";
|
import type { AttributesQueryValue } from "@calcom/lib/raqb/types";
|
||||||
import type { EventTypeTranslation } from "@calcom/prisma/client";
|
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 {
|
import type {
|
||||||
BookerLayoutSettings,
|
BookerLayoutSettings,
|
||||||
CustomInputSchema,
|
CustomInputSchema,
|
||||||
@@ -388,6 +388,7 @@ export type EventTypeUpdateInput = {
|
|||||||
showOptimizedSlots?: boolean | null;
|
showOptimizedSlots?: boolean | null;
|
||||||
disableCancelling?: boolean | null;
|
disableCancelling?: boolean | null;
|
||||||
disableRescheduling?: boolean | null;
|
disableRescheduling?: boolean | null;
|
||||||
|
requiresCancellationReason?: CancellationReasonRequirement | null;
|
||||||
minimumRescheduleNotice?: number | null;
|
minimumRescheduleNotice?: number | null;
|
||||||
seatsShowAttendees?: boolean | null;
|
seatsShowAttendees?: boolean | null;
|
||||||
seatsShowAvailabilityCount?: boolean | null;
|
seatsShowAvailabilityCount?: boolean | null;
|
||||||
|
|||||||
@@ -84,9 +84,9 @@
|
|||||||
"need_to_reschedule_or_cancel": "Need to reschedule or cancel?",
|
"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",
|
"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",
|
"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",
|
"cancelled_by": "Cancelled By",
|
||||||
"cancellation_reason_host": "Reason for cancellation",
|
|
||||||
"cancellation_reason_placeholder": "Why are you cancelling?",
|
"cancellation_reason_placeholder": "Why are you cancelling?",
|
||||||
"notify_attendee_cancellation_reason_warning": "Cancellation reason will be shared with guests",
|
"notify_attendee_cancellation_reason_warning": "Cancellation reason will be shared with guests",
|
||||||
"rejection_reason_placeholder": "Why are you rejecting?",
|
"rejection_reason_placeholder": "Why are you rejecting?",
|
||||||
@@ -1507,8 +1507,14 @@
|
|||||||
"rescheduling_is_disabled": "Rescheduling is disabled for this event",
|
"rescheduling_is_disabled": "Rescheduling is disabled for this event",
|
||||||
"allow_rescheduling_cancelled_bookings": "Allow booking through reschedule link",
|
"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",
|
"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",
|
"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>",
|
"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",
|
"revoke_api_key": "Revoke API key",
|
||||||
"api_key_copied": "API key copied!",
|
"api_key_copied": "API key copied!",
|
||||||
"api_key_expires_on": "The API key will expire on",
|
"api_key_expires_on": "The API key will expire on",
|
||||||
|
|||||||
@@ -168,6 +168,7 @@ export const buildEventType = (eventType?: Partial<EventType>): EventType => {
|
|||||||
createdAt: null,
|
createdAt: null,
|
||||||
updatedAt: null,
|
updatedAt: null,
|
||||||
rrHostSubsetEnabled: false,
|
rrHostSubsetEnabled: false,
|
||||||
|
requiresCancellationReason: null,
|
||||||
enablePerHostLocations: false,
|
enablePerHostLocations: false,
|
||||||
...eventType,
|
...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])
|
@@index([email])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum CancellationReasonRequirement {
|
||||||
|
MANDATORY_BOTH
|
||||||
|
MANDATORY_HOST_ONLY
|
||||||
|
MANDATORY_ATTENDEE_ONLY
|
||||||
|
OPTIONAL_BOTH
|
||||||
|
}
|
||||||
|
|
||||||
model EventType {
|
model EventType {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
/// @zod.string.min(1)
|
/// @zod.string.min(1)
|
||||||
@@ -279,8 +286,9 @@ model EventType {
|
|||||||
restrictionSchedule Schedule? @relation("restrictionSchedule", fields: [restrictionScheduleId], references: [id])
|
restrictionSchedule Schedule? @relation("restrictionSchedule", fields: [restrictionScheduleId], references: [id])
|
||||||
hostGroups HostGroup[]
|
hostGroups HostGroup[]
|
||||||
|
|
||||||
bookingRequiresAuthentication Boolean @default(false)
|
bookingRequiresAuthentication Boolean @default(false)
|
||||||
rrHostSubsetEnabled Boolean @default(false)
|
rrHostSubsetEnabled Boolean @default(false)
|
||||||
|
requiresCancellationReason CancellationReasonRequirement? @default(MANDATORY_HOST_ONLY)
|
||||||
enablePerHostLocations Boolean @default(false)
|
enablePerHostLocations Boolean @default(false)
|
||||||
|
|
||||||
createdAt DateTime? @default(now())
|
createdAt DateTime? @default(now())
|
||||||
|
|||||||
@@ -771,6 +771,7 @@ export const allManagedEventTypeProps: { [k in keyof Omit<Prisma.EventTypeSelect
|
|||||||
disableGuests: true,
|
disableGuests: true,
|
||||||
disableCancelling: true,
|
disableCancelling: true,
|
||||||
disableRescheduling: true,
|
disableRescheduling: true,
|
||||||
|
requiresCancellationReason: true,
|
||||||
allowReschedulingCancelledBookings: true,
|
allowReschedulingCancelledBookings: true,
|
||||||
requiresConfirmation: true,
|
requiresConfirmation: true,
|
||||||
canSendCalVideoTranscriptionEmails: true,
|
canSendCalVideoTranscriptionEmails: true,
|
||||||
@@ -853,6 +854,7 @@ export const allManagedEventTypePropsForZod = {
|
|||||||
disableGuests: true,
|
disableGuests: true,
|
||||||
disableCancelling: true,
|
disableCancelling: true,
|
||||||
disableRescheduling: true,
|
disableRescheduling: true,
|
||||||
|
requiresCancellationReason: true,
|
||||||
allowReschedulingCancelledBookings: true,
|
allowReschedulingCancelledBookings: true,
|
||||||
requiresConfirmation: true,
|
requiresConfirmation: true,
|
||||||
canSendCalVideoTranscriptionEmails: true,
|
canSendCalVideoTranscriptionEmails: true,
|
||||||
|
|||||||
@@ -165,6 +165,10 @@ const BaseEventTypeUpdateInput: z.ZodType<TUpdateInputSchema> = z
|
|||||||
showOptimizedSlots: z.boolean().nullable().optional(),
|
showOptimizedSlots: z.boolean().nullable().optional(),
|
||||||
disableCancelling: z.boolean().nullable().optional(),
|
disableCancelling: z.boolean().nullable().optional(),
|
||||||
disableRescheduling: 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(),
|
minimumRescheduleNotice: z.number().min(0).nullable().optional(),
|
||||||
seatsShowAttendees: z.boolean().nullable().optional(),
|
seatsShowAttendees: z.boolean().nullable().optional(),
|
||||||
seatsShowAvailabilityCount: 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