Files
2026-07-01 01:26:48 +05:30

553 lines
20 KiB
TypeScript

import { DailyLocationType } from "@calcom/app-store/constants";
import { FAKE_DAILY_CREDENTIAL } from "@calcom/app-store/dailyvideo/lib/VideoApiAdapter";
import { eventTypeMetaDataSchemaWithTypedApps } from "@calcom/app-store/zod-utils";
import dayjs from "@calcom/dayjs";
import { sendCancelledEmailsAndSMS } from "@calcom/emails/email-manager";
import { BookingReferenceRepository } from "@calcom/features/bookingReference/repositories/BookingReferenceRepository";
import EventManager from "@calcom/features/bookings/lib/EventManager";
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
import { processNoShowFeeOnCancellation } from "@calcom/features/bookings/lib/payment/processNoShowFeeOnCancellation";
import { processPaymentRefund } from "@calcom/features/bookings/lib/payment/processPaymentRefund";
import {
type EventTypeBrandingData,
getEventTypeService,
} from "@calcom/features/eventtypes/di/EventTypeService.container";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import type { GetSubscriberOptions } from "@calcom/features/webhooks/lib/getWebhooks";
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
import {
cancelNoShowTasksForBooking,
deleteWebhookScheduledTriggers,
} from "@calcom/features/webhooks/lib/scheduleTrigger";
import sendPayload from "@calcom/features/webhooks/lib/sendOrSchedulePayload";
import type { EventTypeInfo } from "@calcom/features/webhooks/lib/sendPayload";
import { HttpError } from "@calcom/lib/http-error";
import { isPrismaObjOrUndefined } from "@calcom/lib/isPrismaObj";
import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { getTranslation } from "@calcom/i18n/server";
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
// TODO: Prisma import would be used from DI in a followup PR when we remove `handler` export
import prisma from "@calcom/prisma";
import type { WebhookTriggerEvents } from "@calcom/prisma/enums";
import { BookingStatus } from "@calcom/prisma/enums";
import { isCancellationReasonRequired } from "./cancellationReason";
import type { EventTypeMetadata } from "@calcom/prisma/zod-utils";
import { bookingCancelInput } from "@calcom/prisma/zod-utils";
import type { CalendarEvent } from "@calcom/types/Calendar";
import type { z } from "zod";
import { BookingRepository } from "../repositories/BookingRepository";
import { PrismaBookingAttendeeRepository } from "../repositories/PrismaBookingAttendeeRepository";
import type {
CancelBookingMeta,
CancelRegularBookingData,
HandleCancelBookingResponse,
} from "./dto/BookingCancel";
import { getAllCredentialsIncludeServiceAccountKey } from "./getAllCredentialsForUsersOnEvent/getAllCredentials";
import { getBookingToDelete } from "./getBookingToDelete";
import cancelAttendeeSeat from "./handleSeats/cancel/cancelAttendeeSeat";
import type { IBookingCancelService } from "./interfaces/IBookingCancelService";
import { isPrismaError } from "@calcom/lib/server/getServerErrorFromUnknown";
const log = logger.getSubLogger({ prefix: ["handleCancelBooking"] });
type PlatformParams = {
platformClientId?: string;
platformRescheduleUrl?: string;
platformCancelUrl?: string;
platformBookingUrl?: string;
arePlatformEmailsEnabled?: boolean;
};
export type BookingToDelete = Awaited<ReturnType<typeof getBookingToDelete>>;
export type CancelBookingInput = {
userId?: number;
userUuid?: string;
bookingData: z.infer<typeof bookingCancelInput>;
actionSource?: string;
actor?: unknown;
} & PlatformParams;
type Dependencies = {
userRepository: UserRepository;
bookingRepository: BookingRepository;
bookingReferenceRepository: BookingReferenceRepository;
attendeeRepository: PrismaBookingAttendeeRepository;
};
async function handler(input: CancelBookingInput, dependencies?: Dependencies) {
const prismaClient = prisma;
const {
userRepository,
bookingRepository,
bookingReferenceRepository,
attendeeRepository,
} = dependencies || {
userRepository: new UserRepository(prismaClient),
bookingRepository: new BookingRepository(prismaClient),
bookingReferenceRepository: new BookingReferenceRepository({ prismaClient }),
attendeeRepository: new PrismaBookingAttendeeRepository(prismaClient),
};
const body = input.bookingData;
const {
id,
uid,
allRemainingBookings,
cancellationReason,
seatReferenceUid,
cancelledBy,
cancelSubsequentBookings,
skipCancellationReasonValidation = false,
skipCalendarSyncTaskCancellation = false,
} = bookingCancelInput.parse(body);
let bookingToDelete: BookingToDelete
try {
bookingToDelete = await getBookingToDelete(id, uid);
} catch (error) {
if (isPrismaError(error) && error.code === "P2025") // Record not found
{
throw new HttpError({
statusCode: 404,
message: "Booking not found.",
});
}
throw error;
}
const {
userId,
platformBookingUrl,
platformCancelUrl,
platformClientId,
platformRescheduleUrl,
arePlatformEmailsEnabled,
} = input;
/**
* Important: We prevent cancelling an already cancelled booking.
* A booking could have been CANCELLED due to a reschedule,
* in which case we simply update the existing calendar event and meeting.
* We want to avoid deleting them by a subsequent cancellation attempt.
*/
if (bookingToDelete.status === BookingStatus.CANCELLED) {
throw new HttpError({
statusCode: 400,
message: "This booking has already been cancelled.",
});
}
if (!bookingToDelete.userId || !bookingToDelete.user) {
throw new HttpError({ statusCode: 400, message: "User not found" });
}
if (bookingToDelete.eventType?.disableCancelling) {
throw new HttpError({
statusCode: 400,
message: "This event type does not allow cancellations",
});
}
const isCancellationUserHost =
bookingToDelete.userId === userId || bookingToDelete.user.email === cancelledBy;
const isReasonRequired = isCancellationReasonRequired(
bookingToDelete.eventType?.requiresCancellationReason,
isCancellationUserHost
);
if (!platformClientId && !cancellationReason?.trim() && isReasonRequired && !skipCancellationReasonValidation) {
throw new HttpError({
statusCode: 400,
message: "Cancellation reason is required",
});
}
if (bookingToDelete.endTime && new Date() > new Date(bookingToDelete.endTime)) {
throw new HttpError({
statusCode: 400,
message: "Cannot cancel a booking that has already ended",
});
}
// If the booking is a seated event and there is no seatReferenceUid we should validate that logged in user is host
if (bookingToDelete.eventType?.seatsPerTimeSlot && !seatReferenceUid) {
const userIsHost = bookingToDelete.eventType.hosts.find((host) => {
if (host.user.id === userId) return true;
});
const userIsOwnerOfEventType = bookingToDelete.eventType.owner?.id === userId;
if (!userIsHost && !userIsOwnerOfEventType) {
throw new HttpError({
statusCode: 401,
message: "User not a host of this event",
});
}
}
// get webhooks
const eventTrigger: WebhookTriggerEvents = "BOOKING_CANCELLED";
const subscriberOptions: GetSubscriberOptions = {
userId: bookingToDelete.userId,
eventTypeId: bookingToDelete.eventTypeId as number,
triggerEvent: eventTrigger,
teamId: null,
orgId: undefined,
oAuthClientId: platformClientId,
};
const eventTypeInfo: EventTypeInfo = {
eventTitle: bookingToDelete?.eventType?.title || null,
eventDescription: bookingToDelete?.eventType?.description || null,
requiresConfirmation: bookingToDelete?.eventType?.requiresConfirmation || null,
price: bookingToDelete?.eventType?.price || null,
currency: bookingToDelete?.eventType?.currency || null,
length: bookingToDelete?.eventType?.length || null,
};
const webhooks = await getWebhooks(subscriberOptions);
const organizer = await userRepository.findByIdOrThrow({
id: bookingToDelete.userId,
});
const attendeesListPromises = [];
for (const attendee of bookingToDelete.attendees) {
attendeesListPromises.push({
name: attendee.name,
email: attendee.email,
timeZone: attendee.timeZone,
phoneNumber: attendee.phoneNumber,
language: {
translate: await getTranslation(attendee.locale ?? "en", "common"),
locale: attendee.locale ?? "en",
},
});
}
const attendeesList = await Promise.all(attendeesListPromises);
const tOrganizer = await getTranslation(organizer.locale ?? "en", "common");
const bookerUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || "https://app.cal.com";
const evt: CalendarEvent = {
bookerUrl,
title: bookingToDelete?.title,
length: bookingToDelete?.eventType?.length,
type: bookingToDelete?.eventType?.slug as string,
additionalNotes: bookingToDelete?.description,
description: bookingToDelete.eventType?.description,
customInputs: isPrismaObjOrUndefined(bookingToDelete.customInputs),
eventTypeId: bookingToDelete.eventTypeId as number,
...getCalEventResponses({
bookingFields: bookingToDelete.eventType?.bookingFields ?? null,
booking: bookingToDelete,
}),
startTime: bookingToDelete?.startTime ? dayjs(bookingToDelete.startTime).format() : "",
endTime: bookingToDelete?.endTime ? dayjs(bookingToDelete.endTime).format() : "",
organizer: {
id: organizer.id,
username: organizer.username || undefined,
email: bookingToDelete?.userPrimaryEmail ?? organizer.email,
name: organizer.name ?? "Nameless",
timeZone: organizer.timeZone,
timeFormat: getTimeFormatStringFromUserTimeFormat(organizer.timeFormat),
language: { translate: tOrganizer, locale: organizer.locale ?? "en" },
},
attendees: attendeesList,
uid: bookingToDelete?.uid,
bookingId: bookingToDelete?.id,
/* Include recurringEvent information only when cancelling all bookings */
recurringEvent: allRemainingBookings
? parseRecurringEvent(bookingToDelete.eventType?.recurringEvent)
: undefined,
location: bookingToDelete?.location,
destinationCalendar: bookingToDelete?.destinationCalendar
? [bookingToDelete?.destinationCalendar]
: bookingToDelete?.user.destinationCalendar
? [bookingToDelete?.user.destinationCalendar]
: [],
cancellationReason: cancellationReason,
seatsPerTimeSlot: bookingToDelete.eventType?.seatsPerTimeSlot,
seatsShowAttendees: bookingToDelete.eventType?.seatsShowAttendees,
iCalUID: bookingToDelete.iCalUID,
iCalSequence: bookingToDelete.iCalSequence + 1,
platformClientId,
platformRescheduleUrl,
platformCancelUrl,
hideOrganizerEmail: bookingToDelete.eventType?.hideOrganizerEmail,
platformBookingUrl,
customReplyToEmail: bookingToDelete.eventType?.customReplyToEmail,
schedulingType: bookingToDelete.eventType?.schedulingType,
hideBranding: bookingToDelete.eventTypeId
? await getEventTypeService().shouldHideBrandingForEventType(bookingToDelete.eventTypeId, {
team: bookingToDelete.eventType?.team
? {
hideBranding: bookingToDelete.eventType.team.hideBranding,
parent: bookingToDelete.eventType.team.parent,
}
: null,
owner: bookingToDelete.user
? {
id: bookingToDelete.user.id,
hideBranding: bookingToDelete.user.hideBranding,
profiles: bookingToDelete.user.profiles ?? [],
}
: null,
} satisfies EventTypeBrandingData)
: false,
};
const dataForWebhooks = { evt, webhooks, eventTypeInfo };
// If it's just an attendee of a booking then just remove them from that booking
const result = await cancelAttendeeSeat(
{
seatReferenceUid: seatReferenceUid,
bookingToDelete,
},
dataForWebhooks,
bookingToDelete?.eventType?.metadata as EventTypeMetadata
);
if (result)
return {
success: true,
onlyRemovedAttendee: true,
bookingId: bookingToDelete.id,
bookingUid: bookingToDelete.uid,
message: "Attendee successfully removed.",
isPlatformManagedUserBooking: bookingToDelete.user.isPlatformManaged,
} satisfies HandleCancelBookingResponse;
const promises = webhooks.map((webhook) =>
sendPayload(webhook.secret, eventTrigger, new Date().toISOString(), webhook, {
...evt,
...eventTypeInfo,
status: "CANCELLED",
smsReminderNumber: bookingToDelete.smsReminderNumber || undefined,
cancelledBy: cancelledBy,
requestReschedule: false,
}).catch((e) => {
logger.error(
`Error executing webhook for event: ${eventTrigger}, URL: ${webhook.subscriberUrl}, bookingId: ${evt.bookingId}, bookingUid: ${evt.uid}`,
safeStringify(e)
);
})
);
await Promise.all(promises);
let updatedBookings: {
id: number;
uid: string;
references: {
type: string;
credentialId: number | null;
uid: string;
externalCalendarId: string | null;
}[];
startTime: Date;
endTime: Date;
}[] = [];
// by cancelling first, and blocking whilst doing so; we can ensure a cancel
// action always succeeds even if subsequent integrations fail cancellation.
if (
bookingToDelete.eventType?.recurringEvent &&
bookingToDelete.recurringEventId &&
(allRemainingBookings || cancelSubsequentBookings)
) {
const recurringEventId = bookingToDelete.recurringEventId;
const gte = cancelSubsequentBookings ? bookingToDelete.startTime : new Date();
// Proceed to mark as cancelled all remaining recurring events instances (greater than or equal to right now)
await bookingRepository.updateMany({
where: {
recurringEventId,
startTime: {
gte,
},
},
data: {
status: BookingStatus.CANCELLED,
cancellationReason: cancellationReason,
cancelledBy: cancelledBy,
},
});
const allUpdatedBookings = await bookingRepository.findManyIncludeReferences({
where: {
recurringEventId: bookingToDelete.recurringEventId,
startTime: {
gte: new Date(),
},
},
});
updatedBookings = updatedBookings.concat(allUpdatedBookings);
} else {
if (bookingToDelete?.eventType?.seatsPerTimeSlot) {
await attendeeRepository.deleteManyByBookingId(bookingToDelete.id);
}
const updatedBooking = await bookingRepository.updateIncludeReferences({
where: {
uid: bookingToDelete.uid,
},
data: {
status: BookingStatus.CANCELLED,
cancellationReason: cancellationReason,
cancelledBy: cancelledBy,
// Assume that canceling the booking is the last action
iCalSequence: evt.iCalSequence || 100,
},
});
updatedBookings.push(updatedBooking);
if (bookingToDelete.payment.some((payment) => payment.paymentOption === "ON_BOOKING")) {
try {
await processPaymentRefund({
booking: bookingToDelete,
});
} catch (error) {
log.error(`Error processing payment refund for booking ${bookingToDelete.uid}:`, error);
}
} else if (bookingToDelete.payment.some((payment) => payment.paymentOption === "HOLD")) {
try {
await processNoShowFeeOnCancellation({
booking: bookingToDelete,
payments: bookingToDelete.payment,
cancelledByUserId: userId,
});
} catch (error) {
log.error(`Error processing no-show fee for booking ${bookingToDelete.uid}:`, error);
}
}
}
/** TODO: Remove this without breaking functionality */
if (bookingToDelete.location === DailyLocationType) {
bookingToDelete.user.credentials.push({
...FAKE_DAILY_CREDENTIAL,
teamId: null,
});
}
const isBookingInRecurringSeries = !!(
bookingToDelete.eventType?.recurringEvent &&
bookingToDelete.recurringEventId &&
allRemainingBookings
);
// Skip calendar event deletion when cancellation comes from a calendar subscription webhook
// to avoid infinite loops (Google/Office365 → Cal.diy → Google/Office365 → ...)
if (!skipCalendarSyncTaskCancellation) {
try {
const bookingToDeleteEventTypeMetadataParsed = eventTypeMetaDataSchemaWithTypedApps.safeParse(
bookingToDelete.eventType?.metadata || null
);
if (!bookingToDeleteEventTypeMetadataParsed.success) {
log.error(
`Error parsing metadata`,
safeStringify({ error: bookingToDeleteEventTypeMetadataParsed?.error })
);
throw new Error("Error parsing metadata");
}
const bookingToDeleteEventTypeMetadata = bookingToDeleteEventTypeMetadataParsed.data;
const credentials = await getAllCredentialsIncludeServiceAccountKey(bookingToDelete.user, {
...bookingToDelete.eventType,
metadata: bookingToDeleteEventTypeMetadata,
});
const eventManager = new EventManager(
{ ...bookingToDelete.user, credentials },
bookingToDeleteEventTypeMetadata?.apps
);
await eventManager.cancelEvent(evt, bookingToDelete.references, isBookingInRecurringSeries);
} catch (error) {
log.error(`Error deleting integrations`, safeStringify({ error }));
}
}
// Always mark booking references as deleted for data consistency
// (even when skipCalendarSyncTaskCancellation is true, since the external event is already deleted)
try {
await bookingReferenceRepository.updateManyByBookingId(bookingToDelete.id, { deleted: true });
} catch (error) {
log.error(`Error marking booking references as deleted`, safeStringify({ error }));
}
try {
const webhookTriggerPromises = [];
for (const booking of updatedBookings) {
webhookTriggerPromises.push(deleteWebhookScheduledTriggers({ booking }));
webhookTriggerPromises.push(cancelNoShowTasksForBooking({ bookingUid: booking.uid }));
}
await Promise.allSettled(webhookTriggerPromises).then((results) => {
const rejectedReasons = results
.filter((result): result is PromiseRejectedResult => result.status === "rejected")
.map((result) => result.reason);
if (rejectedReasons.length > 0) {
log.error("An error occurred when deleting webhook triggers", rejectedReasons);
}
});
} catch (error) {
log.error("Error deleting scheduled webhooks", safeStringify({ error }));
}
try {
// TODO: if emails fail try to requeue them
if (!platformClientId || (platformClientId && arePlatformEmailsEnabled))
await sendCancelledEmailsAndSMS(
evt,
{ eventName: bookingToDelete?.eventType?.eventName },
bookingToDelete?.eventType?.metadata as EventTypeMetadata
);
} catch (error) {
log.error("Error deleting event", error);
}
return {
success: true,
message: "Booking successfully cancelled.",
onlyRemovedAttendee: false,
bookingId: bookingToDelete.id,
bookingUid: bookingToDelete.uid,
isPlatformManagedUserBooking: bookingToDelete.user.isPlatformManaged,
} satisfies HandleCancelBookingResponse;
}
type BookingCancelServiceDependencies = {
userRepository: UserRepository;
bookingRepository: BookingRepository;
profileRepository?: unknown;
bookingReferenceRepository: BookingReferenceRepository;
attendeeRepository: PrismaBookingAttendeeRepository;
};
/**
* Takes care of cancelling bookings. This includes regular bookings, recurring bookings, seated bookings, etc.
* Handles both individual booking cancellations and bulk cancellations for recurring events.
*/
export class BookingCancelService implements IBookingCancelService {
constructor(private readonly deps: BookingCancelServiceDependencies) {}
async cancelBooking(input: { bookingData: CancelRegularBookingData; bookingMeta?: CancelBookingMeta }) {
const cancelBookingInput: CancelBookingInput = {
bookingData: input.bookingData,
...(input.bookingMeta || {}),
};
return handler(cancelBookingInput, this.deps);
}
}
export default handler;