mirror of
https://github.com/hanzoai/calendar.git
synced 2026-10-30 14:46:43 +00:00
When disableCancelling or disableRescheduling is enabled on an event type, the confirmation email still showed non-functional cancel/reschedule links. The ManageLink email component already had correct conditional logic to hide these links, but two flows constructed the CalendarEvent manually without including the flags: the booking confirmation handler and the payment booking flow. This caused the flags to be undefined, which ManageLink interpreted as enabled. Added disableCancelling and disableRescheduling to the Prisma select and CalendarEvent object in both confirm.handler.ts and getBooking.ts. Closes #22906
583 lines
19 KiB
TypeScript
583 lines
19 KiB
TypeScript
import { getUsersCredentialsIncludeServiceAccountKey } from "@calcom/app-store/delegationCredential";
|
|
import type { LocationObject } from "@calcom/app-store/locations";
|
|
import { getLocationValueForDB } from "@calcom/app-store/locations";
|
|
import { sendDeclinedEmailsAndSMS } from "@calcom/emails/email-manager";
|
|
import { getBookingEventHandlerService } from "@calcom/features/bookings/di/BookingEventHandlerService.container";
|
|
import { getFeaturesRepository } from "@calcom/features/di/containers/FeaturesRepository";
|
|
import { getAllCredentialsIncludeServiceAccountKey } from "@calcom/features/bookings/lib/getAllCredentialsForUsersOnEvent/getAllCredentials";
|
|
import { getAssignmentReasonCategory } from "@calcom/features/bookings/lib/getAssignmentReasonCategory";
|
|
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
|
|
import { handleConfirmation } from "@calcom/features/bookings/lib/handleConfirmation";
|
|
import { handleWebhookTrigger } from "@calcom/features/bookings/lib/handleWebhookTrigger";
|
|
import { processPaymentRefund } from "@calcom/features/bookings/lib/payment/processPaymentRefund";
|
|
import { BookingAccessService } from "@calcom/features/bookings/services/BookingAccessService";
|
|
import { CreditService } from "@calcom/features/ee/billing/credit-service";
|
|
import { getBookerBaseUrl } from "@calcom/features/ee/organizations/lib/getBookerUrlServer";
|
|
import { workflowSelect } from "@calcom/features/ee/workflows/lib/getAllWorkflows";
|
|
import { getAllWorkflowsFromEventType } from "@calcom/features/ee/workflows/lib/getAllWorkflowsFromEventType";
|
|
import { WorkflowService } from "@calcom/features/ee/workflows/lib/service/WorkflowService";
|
|
import type { GetSubscriberOptions } from "@calcom/features/webhooks/lib/getWebhooks";
|
|
import type { EventPayloadType, EventTypeInfo } from "@calcom/features/webhooks/lib/sendPayload";
|
|
import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
|
|
import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType";
|
|
import { isPrismaObjOrUndefined } from "@calcom/lib/isPrismaObj";
|
|
import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent";
|
|
import { getTranslation } from "@calcom/lib/server/i18n";
|
|
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
|
|
import type { TraceContext } from "@calcom/lib/tracing";
|
|
import { prisma } from "@calcom/prisma";
|
|
import { Prisma } from "@calcom/prisma/client";
|
|
import { BookingStatus, WebhookTriggerEvents, WorkflowTriggerEvents } from "@calcom/prisma/enums";
|
|
import type { EventTypeMetadata } from "@calcom/prisma/zod-utils";
|
|
import type { CalendarEvent } from "@calcom/types/Calendar";
|
|
import { TRPCError } from "@trpc/server";
|
|
import { v4 as uuidv4 } from "uuid";
|
|
import type { TrpcSessionUser } from "../../../types";
|
|
import type { TConfirmInputSchema } from "./confirm.schema";
|
|
import type { ValidActionSource } from "@calcom/features/booking-audit/lib/types/actionSource";
|
|
import type { Actor } from "@calcom/features/booking-audit/lib/dto/types";
|
|
import type { ISimpleLogger } from "@calcom/features/di/shared/services/logger.service";
|
|
import { safeStringify } from "@calcom/lib/safeStringify";
|
|
import logger from "@calcom/lib/logger";
|
|
type ConfirmOptions = {
|
|
ctx: {
|
|
user: Pick<
|
|
NonNullable<TrpcSessionUser>,
|
|
"id" | "uuid" | "email" | "username" | "role" | "destinationCalendar"
|
|
>;
|
|
traceContext: TraceContext;
|
|
};
|
|
input: TConfirmInputSchema & { actionSource: ValidActionSource; actor: Actor };
|
|
};
|
|
|
|
async function fireRejectionEvent({
|
|
actor,
|
|
organizationId,
|
|
actionSource,
|
|
rejectedBookings,
|
|
rejectionReason,
|
|
isBookingAuditEnabled,
|
|
tracingLogger,
|
|
}: {
|
|
actor: Actor;
|
|
organizationId: number | null;
|
|
rejectionReason: string | null;
|
|
actionSource: ValidActionSource;
|
|
rejectedBookings: {
|
|
uid: string;
|
|
oldStatus: BookingStatus;
|
|
}[];
|
|
isBookingAuditEnabled: boolean;
|
|
tracingLogger: ISimpleLogger;
|
|
}): Promise<void> {
|
|
try {
|
|
const bookingEventHandlerService = getBookingEventHandlerService();
|
|
if (rejectedBookings.length > 1) {
|
|
const operationId = uuidv4();
|
|
await bookingEventHandlerService.onBulkBookingsRejected({
|
|
bookings: rejectedBookings.map((booking) => ({
|
|
bookingUid: booking.uid,
|
|
auditData: {
|
|
rejectionReason,
|
|
status: { old: booking.oldStatus, new: BookingStatus.REJECTED },
|
|
},
|
|
})),
|
|
actor,
|
|
organizationId,
|
|
operationId,
|
|
source: actionSource,
|
|
isBookingAuditEnabled,
|
|
});
|
|
} else if (rejectedBookings.length === 1) {
|
|
const booking = rejectedBookings[0];
|
|
await bookingEventHandlerService.onBookingRejected({
|
|
bookingUid: booking.uid,
|
|
actor,
|
|
organizationId,
|
|
auditData: {
|
|
rejectionReason,
|
|
status: { old: booking.oldStatus, new: BookingStatus.REJECTED },
|
|
},
|
|
source: actionSource,
|
|
isBookingAuditEnabled,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
tracingLogger.error("Error firing booking rejection event", safeStringify(error));
|
|
}
|
|
}
|
|
/**
|
|
* TODO: Convert it to a service as this fn is the single point of entry across trpc, magic-links, and API v2
|
|
*/
|
|
export const confirmHandler = async ({ ctx, input }: ConfirmOptions) => {
|
|
const log = logger.getSubLogger({ prefix: ["confirmHandler"] });
|
|
const {
|
|
bookingId,
|
|
recurringEventId,
|
|
reason: rejectionReason,
|
|
confirmed,
|
|
emailsEnabled,
|
|
platformClientParams,
|
|
actionSource,
|
|
actor,
|
|
} = input;
|
|
|
|
const booking = await prisma.booking.findUniqueOrThrow({
|
|
where: {
|
|
id: bookingId,
|
|
},
|
|
select: {
|
|
title: true,
|
|
description: true,
|
|
customInputs: true,
|
|
startTime: true,
|
|
endTime: true,
|
|
attendees: true,
|
|
eventTypeId: true,
|
|
responses: true,
|
|
metadata: true,
|
|
userPrimaryEmail: true,
|
|
eventType: {
|
|
select: {
|
|
id: true,
|
|
owner: true,
|
|
teamId: true,
|
|
recurringEvent: true,
|
|
title: true,
|
|
slug: true,
|
|
requiresConfirmation: true,
|
|
currency: true,
|
|
length: true,
|
|
description: true,
|
|
price: true,
|
|
bookingFields: true,
|
|
hideOrganizerEmail: true,
|
|
hideCalendarNotes: true,
|
|
hideCalendarEventDetails: true,
|
|
disableGuests: true,
|
|
disableCancelling: true,
|
|
disableRescheduling: true,
|
|
customReplyToEmail: true,
|
|
seatsPerTimeSlot: true,
|
|
seatsShowAttendees: true,
|
|
metadata: true,
|
|
locations: true,
|
|
team: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
parentId: true,
|
|
},
|
|
},
|
|
workflows: {
|
|
select: {
|
|
workflow: {
|
|
select: workflowSelect,
|
|
},
|
|
},
|
|
},
|
|
customInputs: true,
|
|
parentId: true,
|
|
parent: {
|
|
select: {
|
|
teamId: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
location: true,
|
|
userId: true,
|
|
user: {
|
|
select: {
|
|
id: true,
|
|
username: true,
|
|
email: true,
|
|
timeZone: true,
|
|
timeFormat: true,
|
|
name: true,
|
|
destinationCalendar: true,
|
|
locale: true,
|
|
},
|
|
},
|
|
id: true,
|
|
uid: true,
|
|
payment: true,
|
|
destinationCalendar: true,
|
|
paid: true,
|
|
recurringEventId: true,
|
|
status: true,
|
|
smsReminderNumber: true,
|
|
assignmentReason: {
|
|
select: {
|
|
reasonEnum: true,
|
|
reasonString: true,
|
|
},
|
|
orderBy: {
|
|
createdAt: "desc" as const,
|
|
},
|
|
take: 1,
|
|
},
|
|
},
|
|
});
|
|
|
|
const user = booking.user;
|
|
if (!user) {
|
|
throw new TRPCError({ code: "BAD_REQUEST", message: "Booking must have an organizer" });
|
|
}
|
|
|
|
const bookingAccessService = new BookingAccessService(prisma);
|
|
const isUserAuthorizedToConfirmBooking = await bookingAccessService.doesUserIdHaveAccessToBooking({
|
|
userId: ctx.user.id,
|
|
bookingId: bookingId,
|
|
});
|
|
|
|
if (!isUserAuthorizedToConfirmBooking) {
|
|
throw new TRPCError({
|
|
code: "UNAUTHORIZED",
|
|
message: "User is not authorized to confirm this booking",
|
|
});
|
|
}
|
|
|
|
// Do not move this before authorization check.
|
|
// This is done to avoid exposing extra information to the requester.
|
|
if (booking.status === BookingStatus.ACCEPTED) {
|
|
throw new TRPCError({ code: "BAD_REQUEST", message: "Booking already confirmed" });
|
|
}
|
|
|
|
// If booking requires payment and is not paid, we don't allow confirmation
|
|
if (confirmed && booking.payment.length > 0 && !booking.paid) {
|
|
await prisma.booking.update({
|
|
where: {
|
|
id: bookingId,
|
|
},
|
|
data: {
|
|
status: BookingStatus.ACCEPTED,
|
|
},
|
|
});
|
|
|
|
return { message: "Booking confirmed", status: BookingStatus.ACCEPTED };
|
|
}
|
|
|
|
// Cache translations to avoid requesting multiple times.
|
|
const translations = new Map();
|
|
const attendeesListPromises = booking.attendees.map(async (attendee) => {
|
|
const locale = attendee.locale ?? "en";
|
|
let translate = translations.get(locale);
|
|
if (!translate) {
|
|
translate = await getTranslation(locale, "common");
|
|
translations.set(locale, translate);
|
|
}
|
|
return {
|
|
name: attendee.name,
|
|
email: attendee.email,
|
|
timeZone: attendee.timeZone,
|
|
phoneNumber: attendee.phoneNumber,
|
|
language: {
|
|
translate,
|
|
locale,
|
|
},
|
|
};
|
|
});
|
|
|
|
const organizerOrganizationProfile = await prisma.profile.findFirst({
|
|
where: {
|
|
userId: user.id,
|
|
},
|
|
});
|
|
|
|
const organizerOrganizationId = organizerOrganizationProfile?.organizationId;
|
|
|
|
const bookerUrl = await getBookerBaseUrl(
|
|
booking.eventType?.team?.parentId ?? organizerOrganizationId ?? null
|
|
);
|
|
|
|
const attendeesList = await Promise.all(attendeesListPromises);
|
|
const tOrganizer = await getTranslation(booking.user?.locale ?? "en", "common");
|
|
|
|
const evt: CalendarEvent = {
|
|
type: booking?.eventType?.slug as string,
|
|
title: booking.title,
|
|
description: booking.description,
|
|
bookerUrl,
|
|
// TODO: Remove the usage of `bookingFields` in computing responses. We can do that by storing `label` with the response. Also, this would allow us to correctly show the label for a field even after the Event Type has been deleted.
|
|
...getCalEventResponses({
|
|
bookingFields: booking.eventType?.bookingFields ?? null,
|
|
booking,
|
|
}),
|
|
customInputs: isPrismaObjOrUndefined(booking.customInputs),
|
|
startTime: booking.startTime.toISOString(),
|
|
endTime: booking.endTime.toISOString(),
|
|
organizer: {
|
|
id: booking.user?.id,
|
|
email: booking?.userPrimaryEmail || booking.user?.email || "Email-less",
|
|
name: booking.user?.name || "Nameless",
|
|
username: booking.user?.username || undefined,
|
|
usernameInOrg: organizerOrganizationProfile?.username || undefined,
|
|
timeZone: booking.user?.timeZone || "Europe/London",
|
|
timeFormat: getTimeFormatStringFromUserTimeFormat(booking.user?.timeFormat),
|
|
language: { translate: tOrganizer, locale: booking.user?.locale ?? "en" },
|
|
},
|
|
attendees: attendeesList,
|
|
location: booking.location ?? "",
|
|
uid: booking.uid,
|
|
destinationCalendar: booking.destinationCalendar
|
|
? [booking.destinationCalendar]
|
|
: booking.user?.destinationCalendar
|
|
? [booking.user?.destinationCalendar]
|
|
: [],
|
|
requiresConfirmation: booking?.eventType?.requiresConfirmation ?? false,
|
|
hideOrganizerEmail: booking.eventType?.hideOrganizerEmail,
|
|
hideCalendarNotes: booking.eventType?.hideCalendarNotes,
|
|
hideCalendarEventDetails: booking.eventType?.hideCalendarEventDetails,
|
|
eventTypeId: booking.eventType?.id,
|
|
customReplyToEmail: booking.eventType?.customReplyToEmail,
|
|
seatsPerTimeSlot: booking.eventType?.seatsPerTimeSlot,
|
|
seatsShowAttendees: booking.eventType?.seatsShowAttendees,
|
|
disableCancelling: booking.eventType?.disableCancelling ?? false,
|
|
disableRescheduling: booking.eventType?.disableRescheduling ?? false,
|
|
team: booking.eventType?.team
|
|
? {
|
|
name: booking.eventType.team.name,
|
|
id: booking.eventType.team.id,
|
|
members: [],
|
|
}
|
|
: undefined,
|
|
...(platformClientParams ? platformClientParams : {}),
|
|
organizationId: organizerOrganizationId ?? booking.eventType?.team?.parentId ?? null,
|
|
additionalNotes: booking.description,
|
|
assignmentReason: booking.assignmentReason?.[0]?.reasonEnum
|
|
? {
|
|
category: getAssignmentReasonCategory(booking.assignmentReason[0].reasonEnum),
|
|
details: booking.assignmentReason[0].reasonString ?? null,
|
|
}
|
|
: null,
|
|
};
|
|
|
|
const recurringEvent = parseRecurringEvent(booking.eventType?.recurringEvent);
|
|
if (recurringEventId) {
|
|
if (
|
|
!(await prisma.booking.findFirst({
|
|
where: {
|
|
recurringEventId,
|
|
id: booking.id,
|
|
},
|
|
select: {
|
|
id: true,
|
|
},
|
|
}))
|
|
) {
|
|
// FIXME: It might be best to retrieve recurringEventId from the booking itself.
|
|
throw new TRPCError({
|
|
code: "UNAUTHORIZED",
|
|
message: "Recurring event id doesn't belong to the booking",
|
|
});
|
|
}
|
|
}
|
|
const traceContext = {
|
|
...ctx.traceContext,
|
|
bookingUid: booking.uid || "unknown",
|
|
confirmed: String(confirmed),
|
|
eventTypeId: booking.eventType?.id?.toString() || "null",
|
|
userId: user.id.toString(),
|
|
teamId: booking.eventType?.teamId?.toString() || "null",
|
|
};
|
|
|
|
if (recurringEventId && recurringEvent) {
|
|
const groupedRecurringBookings = await prisma.booking.groupBy({
|
|
where: {
|
|
recurringEventId: booking.recurringEventId,
|
|
},
|
|
by: [Prisma.BookingScalarFieldEnum.recurringEventId],
|
|
_count: true,
|
|
});
|
|
// Overriding the recurring event configuration count to be the actual number of events booked for
|
|
// the recurring event (equal or less than recurring event configuration count)
|
|
recurringEvent.count = groupedRecurringBookings[0]._count;
|
|
// count changed, parsing again to get the new value in
|
|
evt.recurringEvent = parseRecurringEvent(recurringEvent);
|
|
}
|
|
|
|
if (confirmed) {
|
|
const credentials = await getUsersCredentialsIncludeServiceAccountKey(user);
|
|
const userWithCredentials = {
|
|
...user,
|
|
credentials,
|
|
};
|
|
const allCredentials = await getAllCredentialsIncludeServiceAccountKey(userWithCredentials, {
|
|
...booking.eventType,
|
|
metadata: booking.eventType?.metadata as EventTypeMetadata,
|
|
});
|
|
const conferenceCredentialId = getLocationValueForDB(
|
|
booking.location ?? "",
|
|
(booking.eventType?.locations as LocationObject[]) || []
|
|
);
|
|
evt.conferenceCredentialId = conferenceCredentialId.conferenceCredentialId;
|
|
|
|
await handleConfirmation({
|
|
user: { ...user, credentials: allCredentials },
|
|
evt,
|
|
recurringEventId,
|
|
prisma,
|
|
bookingId,
|
|
booking,
|
|
emailsEnabled,
|
|
platformClientParams,
|
|
traceContext,
|
|
actionSource,
|
|
actor,
|
|
});
|
|
} else {
|
|
evt.rejectionReason = rejectionReason;
|
|
let rejectedBookings: {
|
|
uid: string;
|
|
oldStatus: BookingStatus;
|
|
}[] = [];
|
|
|
|
if (recurringEventId) {
|
|
// The booking to reject is a recurring event and comes from /booking/upcoming, proceeding to mark all related
|
|
// bookings as rejected.
|
|
const unconfirmedRecurringBookings = await prisma.booking.findMany({
|
|
where: {
|
|
recurringEventId,
|
|
status: BookingStatus.PENDING,
|
|
},
|
|
select: {
|
|
uid: true,
|
|
status: true,
|
|
},
|
|
});
|
|
|
|
await prisma.booking.updateMany({
|
|
where: {
|
|
uid: {
|
|
in: unconfirmedRecurringBookings.map((booking) => booking.uid),
|
|
},
|
|
},
|
|
data: {
|
|
status: BookingStatus.REJECTED,
|
|
rejectionReason,
|
|
},
|
|
});
|
|
|
|
rejectedBookings = unconfirmedRecurringBookings.map((recurringBooking) => ({
|
|
uid: recurringBooking.uid,
|
|
oldStatus: recurringBooking.status,
|
|
}));
|
|
} else {
|
|
// handle refunds
|
|
if (booking.payment.length) {
|
|
await processPaymentRefund({
|
|
booking: booking,
|
|
teamId: booking.eventType?.teamId,
|
|
});
|
|
}
|
|
// end handle refunds.
|
|
|
|
await prisma.booking.update({
|
|
where: {
|
|
id: bookingId,
|
|
},
|
|
data: {
|
|
status: BookingStatus.REJECTED,
|
|
rejectionReason,
|
|
},
|
|
});
|
|
|
|
rejectedBookings = [
|
|
{
|
|
uid: booking.uid,
|
|
oldStatus: booking.status,
|
|
},
|
|
];
|
|
}
|
|
|
|
if (emailsEnabled) {
|
|
await sendDeclinedEmailsAndSMS(evt, booking.eventType?.metadata as EventTypeMetadata);
|
|
}
|
|
|
|
const teamId = await getTeamIdFromEventType({
|
|
eventType: {
|
|
team: { id: booking.eventType?.teamId ?? null },
|
|
parentId: booking?.eventType?.parentId ?? null,
|
|
},
|
|
});
|
|
|
|
const orgId = await getOrgIdFromMemberOrTeamId({ memberId: booking.userId, teamId });
|
|
|
|
const featuresRepository = getFeaturesRepository();
|
|
const isBookingAuditEnabled = orgId
|
|
? await featuresRepository.checkIfTeamHasFeature(orgId, "booking-audit")
|
|
: false;
|
|
|
|
await fireRejectionEvent({
|
|
actor,
|
|
actionSource,
|
|
organizationId: orgId ?? null,
|
|
rejectionReason: rejectionReason ?? null,
|
|
rejectedBookings,
|
|
isBookingAuditEnabled,
|
|
tracingLogger: log,
|
|
});
|
|
|
|
// send BOOKING_REJECTED webhooks
|
|
const subscriberOptions: GetSubscriberOptions = {
|
|
userId: booking.userId,
|
|
eventTypeId: booking.eventTypeId,
|
|
triggerEvent: WebhookTriggerEvents.BOOKING_REJECTED,
|
|
teamId,
|
|
orgId,
|
|
oAuthClientId: platformClientParams?.platformClientId,
|
|
};
|
|
const eventTrigger: WebhookTriggerEvents = WebhookTriggerEvents.BOOKING_REJECTED;
|
|
const eventTypeInfo: EventTypeInfo = {
|
|
eventTitle: booking.eventType?.title,
|
|
eventDescription: booking.eventType?.description,
|
|
requiresConfirmation: booking.eventType?.requiresConfirmation || null,
|
|
price: booking.eventType?.price,
|
|
currency: booking.eventType?.currency,
|
|
length: booking.eventType?.length,
|
|
};
|
|
const webhookData: EventPayloadType = {
|
|
...evt,
|
|
...eventTypeInfo,
|
|
bookingId,
|
|
eventTypeId: booking.eventType?.id,
|
|
status: BookingStatus.REJECTED,
|
|
smsReminderNumber: booking.smsReminderNumber || undefined,
|
|
};
|
|
await handleWebhookTrigger({ subscriberOptions, eventTrigger, webhookData, traceContext });
|
|
|
|
const workflows = await getAllWorkflowsFromEventType(booking.eventType, user.id);
|
|
try {
|
|
const creditService = new CreditService();
|
|
|
|
await WorkflowService.scheduleWorkflowsFilteredByTriggerEvent({
|
|
workflows,
|
|
smsReminderNumber: booking.smsReminderNumber,
|
|
calendarEvent: {
|
|
...evt,
|
|
bookerUrl: bookerUrl,
|
|
eventType: {
|
|
...eventTypeInfo,
|
|
slug: booking.eventType?.slug as string,
|
|
},
|
|
},
|
|
hideBranding: !!booking.eventType?.owner?.hideBranding,
|
|
triggers: [WorkflowTriggerEvents.BOOKING_REJECTED],
|
|
creditCheckFn: creditService.hasAvailableCredits.bind(creditService),
|
|
});
|
|
} catch (error) {
|
|
// Silently fail
|
|
console.error(
|
|
"Error while scheduling workflow reminders for BOOKING_REJECTED:",
|
|
error instanceof Error ? error.message : String(error)
|
|
);
|
|
}
|
|
}
|
|
|
|
const message = confirmed ? "Booking confirmed" : "Booking rejected";
|
|
const status = confirmed ? BookingStatus.ACCEPTED : BookingStatus.REJECTED;
|
|
|
|
return { message, status };
|
|
};
|