Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 33 additions & 20 deletions apps/web/components/booking/actions/bookingActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ export function getEditEventActions(context: BookingActionContext): ActionType[]
isDisabledRescheduling,
getSeatReferenceUid,
isAttendee,
isPending,
t,
} = context;
const seatReferenceUid = getSeatReferenceUid();
Expand Down Expand Up @@ -211,8 +212,8 @@ export function shouldShowPendingActions(context: BookingActionContext): boolean
}

export function shouldShowEditActions(context: BookingActionContext): boolean {
const { isPending, isTabRecurring, isRecurring, isCancelled } = context;
return !isPending && !(isTabRecurring && isRecurring) && !isCancelled;
const { isTabRecurring, isRecurring, isCancelled } = context;
return !(isTabRecurring && isRecurring) && !isCancelled;
}

export function shouldShowRecurringCancelAction(context: BookingActionContext): boolean {
Expand All @@ -235,31 +236,43 @@ export function isActionDisabled(actionId: string, context: BookingActionContext
isAttendee,
isCancelled,
isRejected,
isPending,
} = context;

switch (actionId) {
case "reschedule":
case "reschedule_request":
// Only apply minimum reschedule notice restriction if user is NOT the organizer
// If user is an attendee (or not authenticated), apply the restriction
const isUserOrganizer =
!isAttendee &&
booking.loggedInUser?.userId &&
booking.user?.id &&
booking.loggedInUser.userId === booking.user.id;
const isWithinMinimumNotice =
!isUserOrganizer &&
isWithinMinimumRescheduleNotice(
new Date(booking.startTime),
!isAttendee &&
booking.loggedInUser?.userId &&
booking.user?.id &&
booking.loggedInUser.userId === booking.user.id;
const isWithinMinimumNotice =
!isUserOrganizer &&
isWithinMinimumRescheduleNotice(
new Date(booking.startTime),
booking.eventType.minimumRescheduleNotice ?? null
);
return (
isCancelled ||
isRejected ||
(isBookingInPast && !booking.eventType.allowReschedulingPastBookings) ||
isDisabledRescheduling ||
isWithinMinimumNotice
return (
isCancelled ||
isRejected ||
(isBookingInPast && !booking.eventType.allowReschedulingPastBookings) ||
isDisabledRescheduling ||
isWithinMinimumNotice
);
case "reschedule_request":
const isWithinMinimumNoticeForRequest =
isWithinMinimumRescheduleNotice(
new Date(booking.startTime),
booking.eventType.minimumRescheduleNotice ?? null
);
return (
isPending ||
isCancelled ||
isRejected ||
(isBookingInPast && !booking.eventType.allowReschedulingPastBookings) ||
isDisabledRescheduling ||
isWithinMinimumNoticeForRequest
);
case "cancel":
return isDisabledCancelling || isBookingInPast || isCancelled || isRejected;
case "view_recordings":
Expand All @@ -271,7 +284,7 @@ export function isActionDisabled(actionId: string, context: BookingActionContext
case "reassign":
case "change_location":
case "add_members":
return isBookingInPast || isCancelled || isRejected;
return isBookingInPast || isCancelled || isRejected || isPending;
default:
return false;
}
Expand Down
38 changes: 37 additions & 1 deletion packages/emails/email-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ import OrganizerRequestReminderEmail from "./templates/organizer-request-reminde
import OrganizerRequestedToRescheduleEmail from "./templates/organizer-requested-to-reschedule-email";
import OrganizerRescheduledEmail from "./templates/organizer-rescheduled-email";
import OrganizerScheduledEmail from "./templates/organizer-scheduled-email";
import AttendeePendingRescheduledEmail from "./templates/attendee-pending-rescheduled-email";
import OrganizerPendingRescheduledEmail from "./templates/organizer-pending-rescheduled-email";
import { BookingStatus } from "@calcom/prisma/enums";

type EventTypeMetadata = z.infer<typeof EventTypeMetaDataSchema>;

Expand Down Expand Up @@ -282,12 +285,45 @@ export const sendReassignedEmailsAndSMS = async (args: {

const _sendRescheduledEmailsAndSMS = async (
calEvent: CalendarEvent,
eventTypeMetadata?: EventTypeMetadata
eventTypeMetadata?: EventTypeMetadata,
originalBookingStatus?: BookingStatus,
) => {
const calendarEvent = formatCalEvent(calEvent);
const emailsToSend: Promise<unknown>[] = [];
const organizationSettings = await fetchOrganizationEmailSettings(calEvent.organizationId);

const wasPending = originalBookingStatus === BookingStatus.PENDING;

if (wasPending) {
const attendeeCalEvent = {
...calendarEvent,
...(calendarEvent.hideCalendarNotes && { additionalNotes: undefined }),
};

if (!eventTypeDisableHostEmail(eventTypeMetadata)) {
emailsToSend.push(sendEmail(() => new OrganizerPendingRescheduledEmail({ calEvent: calendarEvent })));

if (calendarEvent.team) {
for (const teamMember of calendarEvent.team.members) {
emailsToSend.push(
sendEmail(() => new OrganizerPendingRescheduledEmail({ calEvent: calendarEvent, teamMember }))
);
}
}
}

if (!shouldSkipAttendeeEmailWithSettings(eventTypeMetadata, organizationSettings, EmailType.RESCHEDULED)) {
emailsToSend.push(
...calendarEvent.attendees.map((attendee) =>
sendEmail(() => new AttendeePendingRescheduledEmail(attendeeCalEvent, attendee))
)
);
}

await Promise.all(emailsToSend);
return;
}

if (!eventTypeDisableHostEmail(eventTypeMetadata)) {
emailsToSend.push(sendEmail(() => new OrganizerRescheduledEmail({ calEvent: calendarEvent })));

Expand Down
38 changes: 38 additions & 0 deletions packages/emails/templates/attendee-pending-rescheduled-email.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import renderEmail from "../src/renderEmail";
import AttendeeScheduledEmail from "./attendee-scheduled-email";

export default class AttendeePendingRescheduledEmail extends AttendeeScheduledEmail {
constructor(calEvent: CalendarEvent, attendee: Person) {
super(calEvent, attendee);
this.name = "SEND_PENDING_RESCHEDULE_NOTIFICATION";
}

protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
// No ICS attachment: booking is still PENDING, no calendar event should be
// created yet. We deliberately omit the icalEvent field here.
return {
to: `${this.attendee.name} <${this.attendee.email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
subject: this.attendee.language.translate(
"rescheduled_pending_event_type_subject",
{
title: this.calEvent.title,
date: this.getFormattedDate(),
}
),
html: await this.getHtml(this.calEvent, this.attendee),
text: this.getTextBody(
"rescheduled_pending_attendee_email_title",
"rescheduled_pending_attendee_email_subtitle"
),
};
}

async getHtml(calEvent: CalendarEvent, attendee: Person) {
return await renderEmail("AttendeeRescheduledEmail", {
calEvent,
attendee,
});
}
}
46 changes: 46 additions & 0 deletions packages/emails/templates/organizer-pending-rescheduled-email.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { EMAIL_FROM_NAME } from "@calcom/lib/constants";
import { getReplyToHeader } from "@calcom/lib/getReplyToHeader";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import renderEmail from "../src/renderEmail";
import OrganizerScheduledEmail from "./organizer-scheduled-email";

export default class OrganizerPendingRescheduledEmail extends OrganizerScheduledEmail {
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
const toAddresses = [this.teamMember?.email || this.calEvent.organizer.email];

return {
from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`,
to: toAddresses.join(","),
...getReplyToHeader(
this.calEvent,
this.calEvent.attendees.map(({ email }) => email),
true
),
subject: this.calEvent.organizer.language.translate(
"rescheduled_pending_event_type_subject",
{
title: this.calEvent.title,
date: this.getFormattedDate(),
}
),
html: await this.getHtml(
{ ...this.calEvent, attendeeSeatId: undefined },
this.calEvent.organizer,
this.teamMember
),

text: this.getTextBody(
"rescheduled_pending_organizer_email_title",
"rescheduled_pending_organizer_email_subtitle"
),
};
}

async getHtml(calEvent: CalendarEvent, attendee: Person, teamMember?: Person) {
return await renderEmail("OrganizerRescheduledEmail", {
calEvent,
attendee,
teamMember,
});
}
}
34 changes: 33 additions & 1 deletion packages/features/bookings/lib/BookingEmailSmsHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type { EventTypeMetadata } from "@calcom/prisma/zod-utils";
import type { AdditionalInformation, CalendarEvent, Person } from "@calcom/types/Calendar";
import { default as cloneDeep } from "lodash/cloneDeep";
import type { Logger } from "tslog";
import { BookingStatus } from "@calcom/prisma/enums";

export const BookingActionMap = {
confirmed: "BOOKING_CONFIRMED",
Expand Down Expand Up @@ -54,6 +55,8 @@ type ConfirmedEmailAndSmsPayload = EmailAndSmsPayload & {
type RequestedEmailAndSmsPayload = EmailAndSmsPayload & {
attendees?: Person[];
additionalNotes?: string | null;
originalRescheduledBooking?: NonNullable<BookingType>;
rescheduleReason?: string;
};

type AddGuestsEmailAndSmsPayload = EmailAndSmsPayload & {
Expand Down Expand Up @@ -115,6 +118,7 @@ export class BookingEmailSmsHandler {
rescheduleReason,
additionalNotes,
additionalInformation,
originalRescheduledBooking,
} = data;

const { sendRescheduledEmailsAndSMS } = await import("@calcom/emails/email-manager");
Expand All @@ -125,7 +129,8 @@ export class BookingEmailSmsHandler {
additionalNotes,
cancellationReason: `$RCH$${rescheduleReason || ""}`,
},
metadata
metadata,
originalRescheduledBooking.status,
);
}

Expand Down Expand Up @@ -294,11 +299,38 @@ export class BookingEmailSmsHandler {
eventType: { metadata },
attendees,
additionalNotes,
originalRescheduledBooking,
rescheduleReason,
} = data;
if (!attendees?.length) {
this.log.error("Requested action called without attendee details.");
return;
}

if (originalRescheduledBooking?.status === BookingStatus.PENDING) {
this.log.debug(
"Action: BOOKING_REQUESTED via pending reschedule. Sending rescheduled pending emails.",
safeStringify({ calEvent: getPiiFreeCalendarEvent(evt) })
);

const { sendRescheduledEmailsAndSMS } = await import("@calcom/emails/email-manager");

try {
await sendRescheduledEmailsAndSMS(
{
...evt,
additionalNotes,
cancellationReason: `$RCH$${rescheduleReason || ""}`,
},
metadata,
originalRescheduledBooking.status,
);
} catch (err) {
this.log.error("Failed to send pending reschedule emails", err);
}
return;
}

this.log.debug(
"Action: BOOKING_REQUESTED. Sending request emails.",
safeStringify({ calEvent: getPiiFreeCalendarEvent(evt) })
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import dayjs from "@calcom/dayjs";
import { checkIfFreeEmailDomain } from "@calcom/features/watchlist/lib/freeEmailDomainCheck/checkIfFreeEmailDomain";
import { withReporting } from "@calcom/lib/sentryWrapper";
import { BookingStatus } from "@calcom/prisma/enums";
import type { OriginalRescheduledBooking } from "./originalRescheduledBookingUtils";

import type { getEventTypeResponse } from "./getEventTypesFromDB";

Expand All @@ -15,22 +17,26 @@ export async function getRequiresConfirmationFlags({
bookingStartTime,
userId,
paymentAppData,
originalRescheduledBookingOrganizerId,
originalRescheduledBooking,
bookerEmail,
}: {
eventType: EventType;
bookingStartTime: string;
userId: number | undefined;
paymentAppData: PaymentAppData;
originalRescheduledBookingOrganizerId: number | undefined;
originalRescheduledBooking: OriginalRescheduledBooking | null,
bookerEmail: string;
}) {
const requiresConfirmation = await determineRequiresConfirmation(eventType, bookingStartTime, bookerEmail);
const originalRescheduledBookingOrganizerId = originalRescheduledBooking?.user?.id
const userReschedulingIsOwner = isUserReschedulingOwner(userId, originalRescheduledBookingOrganizerId);
const originalWasPending = originalRescheduledBooking?.status === BookingStatus.PENDING

const isConfirmedByDefault = determineIsConfirmedByDefault(
requiresConfirmation,
paymentAppData.price,
userReschedulingIsOwner
userReschedulingIsOwner,
originalWasPending
);

return {
Expand Down Expand Up @@ -87,7 +93,8 @@ function isUserReschedulingOwner(
function determineIsConfirmedByDefault(
requiresConfirmation: boolean,
price: number,
userReschedulingIsOwner: boolean
userReschedulingIsOwner: boolean,
originalWasPending: boolean,
): boolean {
return (!requiresConfirmation && price === 0) || userReschedulingIsOwner;
return (!requiresConfirmation && price === 0) || userReschedulingIsOwner && !originalWasPending;
}
Loading
Loading